# 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:

```js title="src/index.js"
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

```bash
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:

```js
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

```bash
apoxy bundle push registry.example.com/acme/hello
```

This 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:

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

## The Service manifest

```yaml title="service.yaml"
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:

```yaml
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

```bash
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:

```bash title="terminal"
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:

```yaml title="service.yaml"
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:

```bash title="terminal"
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.

<Callout label="How it works" variant="info">
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.
</Callout>

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:

```yaml title="service.yaml"
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:

```yaml title="egress.yaml"
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`.

```bash
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:

```yaml title="route.yaml"
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
```

```bash
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](/docs/guides/custom-domains.md).

## 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:

```yaml
spec:
  liveRevision: hello-1a2b3c4d5e  # pinned; clear to resume auto-promotion
```

---

**Navigation** (Guides)

- Previous: [Routing traffic](/docs/guides/routing-traffic.md)
- Next: [Custom domains](/docs/guides/custom-domains.md)
- All pages: [index](/docs/llms.txt)
