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. The unit of deployment is a bundle: an OCI artifact containing your modules plus a manifest describing them. 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 the latest ready revision serves; 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" }); }, };

The entrypoint is discovered from package.json (module, then main) or common locations (src/index.ts, index.js, ...); override with --entry.

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 Service manifest

$service.yamlYAML
apiVersion: compute.apoxy.dev/v1alpha1 kind: Service metadata: name: hello spec: template: spec: backend: protocol: http1 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.

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.

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.

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: backend: protocol: http1 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.

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: backend: protocol: http1 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 get services hello -o yaml # status.conditions: EgressReady apoxy get egressgateways locked-down -o yaml apoxy get egressroutes 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.

Rollouts and rollback

Each apoxy deploy mints a new ServiceRevision and shifts traffic make-before-break: the new revision warms up before the old one drains, so requests never drop across a deploy. The Service's status.liveRevision reports what is serving and status.latestRevision what was minted last. To roll back, pin a previous revision:

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