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.liveRevisionto pin or roll back. - Traffic reaches a Service through the Gateway API: an
HTTPRoutewhosebackendRefsnames the Service.
Project layout
Any esbuild-compatible JS/TS project works. The entrypoint exports the standard fetch handler:
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
apoxy buildThis 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:
import model from "./model.wasm"; // stays a separate wasm module
import prompt from "./prompt.txt"; // stays a separate text moduleUse --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
apoxy bundle push registry.example.com/acme/helloThis packages the staged build as an OCI artifact and prints the immutable reference to pin:
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:
echo "$REGISTRY_TOKEN" | apoxy bundle push ghcr.io/acme/hello --username acme-ci --password-stdinThe Service manifest
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` automaticallyDigests 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:
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
apoxy deployapoxy 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:
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 onlyScopes 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:
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: tokenThe 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:
apoxy secret unset hello-secrets token
apoxy secret delete hello-secretsOutbound 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.
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:
spec:
template:
spec:
backend:
protocol: http1
egress:
gatewayRef: locked-down # a compute.apoxy.dev EgressGateway
# or, to hard-deny all outbound traffic instead:
# disabled: truegatewayRef 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:
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: 443Match 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.
apoxy get services hello -o yaml # status.conditions: EgressReady
apoxy get egressgateways locked-down -o yaml
apoxy get egressroutes allow-openai -o yamlRoute traffic to it
Attach the Service to your Gateway with an HTTPRoute backendRef:
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: helloapoxy 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:
spec:
liveRevision: hello-1a2b3c4d5e # pinned; clear to resume auto-promotion