Getting startedGuidesReferenceChangelog
Apoxy:// Docs / Guides / Compute Services

Deploying compute Services

Build a JS/TS worker into a bundle, push it to an OCI registry, and serve it from the Apoxy edge with `apoxy deploy`.

Compute Services run your JavaScript/TypeScript workers on the Apoxy edge. Your code ships as a bundle - an OCI artifact containing your modules plus a manifest describing them - and the unit of deployment is a ServiceRevision: an immutable record pinning a bundle digest, minted on every deploy, that rolls out and serves requests.

The apoxy CLI covers the whole local-first flow - build, push, and apply - and apoxy deploy runs all three in one step.

The model

  • A Service (compute.apoxy.dev/v1alpha1) names where its bundle comes from (spec.source.oci) and how it serves (spec.template).
  • Every source change mints an immutable ServiceRevision pinned to a bundle digest. By default, each runtime attempts the latest minted revision and keeps its current revision if the new one cannot warm. Set spec.liveRevision to pin or roll back.
  • Traffic reaches a Service through the Gateway API: an HTTPRoute whose backendRefs names the Service.

Project layout

Any esbuild-compatible JS/TS project works. The entrypoint exports the standard fetch handler:

$src/index.jsJS
export default { async fetch(request) { return Response.json({ hello: "world" }); }, };

Deploy in one step

$terminalSH
apoxy deploy

apoxy deploy builds the project, pushes the bundle to spec.source.oci.repo from service.yaml, writes the pushed digest into spec.source.oci.digest, and server-side applies the Service - so the digest is always pinned by machinery, never hand-copied. Use -f for a different manifest path, --no-build to push an already-staged bundle, and the same --username/--password-stdin flags as bundle push.

When spec.source.oci.repo is not set, apoxy deploy pushes to the hosted registry at registry.apoxy.dev/<project-id>/<service-name>: no repository to provision, no registry credentials to manage. Set repo only when you want to bring your own registry.

If no service.yaml exists, deploy generates a minimal manifest with a random name. Pass --name to choose the name yourself.

You can deploy a standalone file directly. apoxy deploy ./worker.js uses the file's directory as the project directory. It reads or creates service.yaml there and writes the bundle under .apoxy/build there.

For a project or monorepo package, pass its directory and set a nested entrypoint:

$terminalSH
apoxy deploy ./edge --entry src/worker.js

Without a file argument or --entry, the CLI discovers the entrypoint from package.json (module, then main) or common locations (src/index.ts, index.js, ...).

Deploy step by step

apoxy deploy is a convenience over stages you can run separately - useful in CI, or when the build/push and the apply happen in different places. Build and push are covered below; the final stage is a plain apoxy apply -f service.yaml with the pushed digest pinned in spec.source.oci.digest.

Build

$terminalSH
apoxy build

This bundles the project with esbuild (ESM output, workerd resolution conditions) into .apoxy/build. Local imports are inlined into a single entry module; imports of .wasm, .txt, .bin, and .data files become separate bundle modules that workerd resolves natively at runtime:

$terminalJS
import model from "./model.wasm"; // stays a separate wasm module import prompt from "./prompt.txt"; // stays a separate text module

Use --compatibility-date and --compatibility-flags to select workerd runtime behavior. The default date is fixed, so rebuilding an unchanged project never changes semantics silently.

Push

$terminalSH
apoxy bundle push registry.example.com/acme/hello

This packages the staged build as an OCI artifact and prints the immutable reference to pin:

$terminalTXT
registry.example.com/acme/hello@sha256:0b7d...

Pushes authenticate with your local docker credential store (whatever docker push would use). For CI, pass explicit credentials:

$terminalSH
echo "$REGISTRY_TOKEN" | apoxy bundle push ghcr.io/acme/hello --username acme-ci --password-stdin

The hosted registry

Every hosted project has a bundle registry built in at registry.apoxy.dev, with repositories namespaced by project ID:

$terminalSH
apoxy bundle push registry.apoxy.dev/<project-id>/hello

Pushes to this host need no docker login and no --username: the CLI authenticates with the project API key it already holds. Outside the CLI you can authenticate with basic auth: username apoxy and your project API key as password. A key can push and pull only under its own project's <project-id>/ prefix, and the platform pulls bundles from this registry without any credentials block in the Service source.

The Service manifest

$service.yamlYAML
apiVersion: compute.apoxy.dev/v1alpha1 kind: Service metadata: name: hello spec: source: oci: repo: registry.example.com/acme/hello digest: sha256:0b7d... # written by `apoxy deploy` automatically

Digests are strongly preferred over tags: the serving path is digest-addressed and immutable, so a re-pushed tag can never silently change what runs.

A tag works too, and is resolved for you:

$terminalYAML
source: oci: repo: registry.example.com/acme/hello tag: latest

The tag is resolved to a digest when you apply the Service, and that digest is what runs from then on - every replica pulls the same artifact. Re-pushing the tag does not roll the service on its own; re-apply the Service to pick up the tag's new target.

Serving configuration lives under spec.template.spec and is fully defaulted - a manifest with just a source is complete. Requests are delivered to the worker over HTTP/1.1 by default; set spec.template.spec.backend.protocol: http2 for workers that need end-to-end HTTP/2 (gRPC).

Private registries

If the bundle repository requires pull authentication, add inline credentials to the source:

$terminalYAML
source: oci: repo: registry.example.com/acme/hello credentials: username: pull-bot password: "..." # write-only: stored as passwordData, never read back # or, for token-issuing registries: # accessToken: "..." # sent as a bearer directly # refreshToken: "..." # OAuth2 identity token (e.g. Azure Container Registry)

Username/password covers basic auth and the standard registry token-service exchange (Docker Hub and GHCR personal access tokens, Google Artifact Registry oauth2accesstoken, ECR authorization tokens). Prefer short-lived tokens where available.

Secrets

API keys and other credentials live in a SecretStore, a project-level object holding a map of named values. Values are write-only: you can set and replace them, but no read of the store (or of anything else in the API) ever returns them. Workers receive them at load time as environment bindings.

At rest, every value is envelope-encrypted (AES-256-GCM) under a key that belongs to your project alone, held in Apoxy's control plane rather than next to the data. Ciphertext is additionally bound to your project, the store, and the key name, so it can never be decrypted for another tenant or replayed into a different store or key. Deleting a project destroys its key, which permanently destroys every value sealed under it.

Create a store and set values with the CLI:

$terminalSH
apoxy secret create hello-secrets --scope compute:hello printf '%s' "$API_TOKEN" | apoxy secret set hello-secrets token apoxy secret list hello-secrets # key names and value digests only

Scopes restrict which consumers may bind the store. Each scope is <surface> or <surface>:<name-glob>, so compute:hello admits only the Service named hello and compute:frontend-* admits any Service whose name matches the glob. A store with no scopes is open to every consumer in the project.

Bind a value into the Service through bindings in the template:

$service.yamlYAML
spec: template: spec: bindings: - name: API_TOKEN # exposed to worker code as env.API_TOKEN type: secret secret: store: hello-secrets key: token

The API rejects a Service whose bindings reference a missing store, an out-of-scope store, or a key that has not been set, so create the store and set its keys before deploying. Binding names share the environment namespace with env entries and must not collide.

Values are resolved when a worker is loaded and baked into that instance. Rotating a value does not restart running workers: set the new value, then redeploy (or otherwise mint a new revision) to pick it up.

To remove a key or a whole store:

$terminalSH
apoxy secret unset hello-secrets token apoxy secret delete hello-secrets

Outbound traffic (egress)

Workers make outbound requests with plain fetch(): there is no binding and no wrapper to import. Egress is mediated host-side by an egress gateway, so what a worker may reach is policy, not code.

How it works

Every outbound connection leaves the worker sandbox through a host-side policy check: DNS names resolve inside the sandbox (so fetch("https://api.stripe.com") just works), the resolved connection is matched against your egress policy by destination IP, port, and the hostname it was resolved from, and anything not allowed fails closed: the connection is refused at connect time (the worker sees a connection-refused error rather than an established connection that goes dead). Private, link-local, and cloud-metadata addresses are always refused, even under an allow-all policy. Non-DNS UDP is not yet supported.

Services published on your VPC networks are reachable from workers by their VPC name: fetch("http://<hostname>.<network>.vpc.apoxy.net:<port>/") resolves and connects like any other destination, scoped to your own project's services. The Service manifest does not need a binding; follow Private services with VPC tunnels for a complete worker-to-VPC example.

By default every Service uses the project's default gateway, which allows all destinations, so fetch works out of the box. Two knobs change that, via the egress block in the Service template:

$service.yamlYAML
spec: template: spec: egress: gatewayRef: locked-down # a compute.apoxy.dev EgressGateway # or, to hard-deny all outbound traffic instead: # disabled: true

gatewayRef and disabled: true are mutually exclusive. An absent egress block (or an empty gatewayRef) means the default gateway.

Egress gateways and routes

An EgressGateway declares listeners and a default policy; EgressRoute objects attach to it and allow specific destinations. A gateway you create defaults to deny-all, so traffic flows only where a route allows it:

$egress.yamlYAML
apiVersion: compute.apoxy.dev/v1alpha1 kind: EgressGateway metadata: name: locked-down spec: defaultPolicy: deny-all # the default for created gateways listeners: - name: https protocol: HTTPS --- apiVersion: compute.apoxy.dev/v1alpha1 kind: EgressRoute metadata: name: allow-openai spec: parentRefs: - name: locked-down # sectionName: https to target one listener rules: - matches: # matches are ORed; dimensions within a match are ANDed - destinationHostnames: ["api.openai.com", "*.openai.com"] - destinationCIDRs: ["192.0.2.0/24"] ports: - port: 443

Match dimensions: destinationHostnames (exact or single-label wildcard; IPs are not hostnames, use CIDRs), destinationCIDRs (host bits must be zero; single IPs as /32//128), ports (port or startPort+endPort), and protocol (TCP; UDP is not yet supported).

Hostname rules match by DNS attribution: a connection matches when its destination address came from a DNS answer the worker recently received for that hostname. A fetch() to a literal IP the worker has not resolved from an allowlisted name carries no hostname and can only match CIDR rules.

To override the built-in allow-all default for the whole project, create an EgressGateway named default: every Service without an explicit gatewayRef resolves to it by name. Note that a created gateway defaults to deny-all, so doing this flips the project to fail-closed unless you set defaultPolicy: allow-all or add routes.

Checking egress status

The control plane reports how each object resolved. A Service carries an EgressReady condition (reason Applied, Disabled, GatewayNotFound for a dangling gatewayRef, which fails closed, or GatewayNotReady while the gateway's data plane is not serving). An EgressGateway reports Ready plus per-listener attachedRoutes counts in status.listeners; an EgressRoute reports per-parent Accepted conditions in status.parents.

$terminalSH
apoxy compute service get hello -o yaml # status.conditions: EgressReady apoxy vpc egressgateway get locked-down -o yaml apoxy vpc egressroute get allow-openai -o yaml

Route traffic to it

Attach the Service to your Gateway with an HTTPRoute backendRef:

$route.yamlYAML
apiVersion: gateway.apoxy.dev/v1 kind: HTTPRoute metadata: name: hello spec: parentRefs: - name: default hostnames: - hello.your-org.apoxy.app rules: - backendRefs: - group: compute.apoxy.dev kind: Service name: hello
$terminalSH
apoxy apply -f route.yaml curl https://hello.your-org.apoxy.app/

The hostname must be covered by a domain in your project; see Custom domains.

Check deployment status

Inspect the Service and its revisions:

$terminalSH
apoxy compute service list apoxy compute service versions hello

The table status separates control-plane progress from reported runtime readiness:

  • Pending - The Service has not been accepted and minted.
  • An acceptance reason such as AwaitingBuild or InvalidSource - The controller cannot mint the revision yet.
  • ReadinessUnknown - The revision was minted, but no aggregate data-plane readiness report is available. This state does not mean that the Service is unavailable.
  • Ready or a readiness reason - A readiness writer reported a verdict.

Send a request through the Service route to verify the full serving path when the table shows ReadinessUnknown.

Rollouts and rollback

Each apoxy deploy mints a new ServiceRevision. Each runtime warms the new revision before it changes its local route. The Service's status.latestRevision reports what was minted last. status.liveRevision reports the selected revision, but it does not prove that every data-plane node serves it. To roll back, pin a previous revision:

$terminalYAML
spec: liveRevision: hello-1a2b3c4d5e # pinned; clear to resume auto-promotion