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.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" });
},
};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.
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:
apoxy deploy ./edge --entry src/worker.jsWithout 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
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 hosted registry
Every hosted project has a bundle registry built in at registry.apoxy.dev, with repositories
namespaced by project ID:
apoxy bundle push registry.apoxy.dev/<project-id>/helloPushes 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
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` 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.
A tag works too, and is resolved for you:
source:
oci:
repo: registry.example.com/acme/hello
tag: latestThe 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:
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:
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:
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.
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:
spec:
template:
spec:
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 compute service get hello -o yaml # status.conditions: EgressReady
apoxy vpc egressgateway get locked-down -o yaml
apoxy vpc egressroute get 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.
Check deployment status
Inspect the Service and its revisions:
apoxy compute service list
apoxy compute service versions helloThe table status separates control-plane progress from reported runtime readiness:
Pending- The Service has not been accepted and minted.- An acceptance reason such as
AwaitingBuildorInvalidSource- 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.Readyor 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:
spec:
liveRevision: hello-1a2b3c4d5e # pinned; clear to resume auto-promotion