Skip to content

Search docs

Jump between documentation pages.

Browse docs

Deployment

Deployment answers a different question from adapters. The adapter docs explain which runtime contract DaloyJS plugs into. This page helps you choose where to run a DaloyJS REST API and shows the packaging and platform config that matter in production.

Build to run
  1. 01ciBuildpnpm install --frozen-lockfile, pnpm build
  2. 02Packagedistroless image or dist/
  3. 03Releasesigned image + SBOM attestation
  4. 04RunNode adapter, graceful shutdown
The same pipeline underpins every Node platform below. Only the packaging and platform config differ. The build, release, and run stages stay the same.

Node platforms

These providers all run the Node adapter. What changes is the platform config, health checks, and rollout mechanics.

Google Cloud Run

Cloud Run runs the Docker image below as-is. Three things matter:

  • Listen on $PORT. Cloud Run injects it (default 8080): serve(app, { port: Number(process.env.PORT ?? 8080) }).
  • Shutdown budget. Cloud Run sends SIGTERM and waits 10 seconds before SIGKILL. The Node adapter's default shutdownTimeoutMs is also 10 seconds, so lower it (for example to 8_000) to finish draining before the kill.
  • Client IPs. Requests arrive through Google's front end, so set behindProxy: { hops: 1 }. Add one hop for each extra proxy, for example an external Application Load Balancer in front of Cloud Run.
bash
gcloud run deploy my-api --source . --region europe-north1 --allow-unauthenticated

--allow-unauthenticated only makes the service reachable; your DaloyJS routes still do their own auth. Cloud Run functions (the former Cloud Functions) expect an Express-style (req, res) handler, which DaloyJS does not ship, so deploy the API as a Cloud Run service instead.

Production checklist

  • Set NODE_ENV=production (or env: "production"). 5xx detail is already redacted when NODE_ENV is unset, but production mode also turns on the refuse-to-boot guards and turns off auto-mounted docs.
  • Keep the app-wide bodyLimitBytes small and raise it only on the routes that need more, with a per-route bodyLimitBytes (since 1.21.0).
  • Set requestTimeoutMs to less than your load balancer's idle timeout.
  • Mount rateLimit() globally. new App() already applies secureHeaders() and a request id to every response; register your own secureHeaders() only to change its options.
  • Declare behindProxy for your platform (see Reverse proxy). Behind any proxy, leaving it unset makes production answer 500 to the first request that carries X-Forwarded-For.
  • Register health and readiness probes with app.healthcheck() and app.readinesscheck() and point your platform's checks at them.
  • Wire your structured logger and propagate request-id to downstream calls.
  • Run contract tests in CI, fail the build if the spec drifts.
  • Use pnpm install --frozen-lockfile in CI. Never pnpm install.

Docker (Node, distroless)

dockerfile
# syntax=docker/dockerfile:1
FROM node:24-bookworm AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml .npmrc ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --prod --ignore-scripts

FROM node:24-bookworm AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml .npmrc ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --ignore-scripts
COPY . .
RUN pnpm build

FROM gcr.io/distroless/nodejs24-debian12 AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps  /app/node_modules ./node_modules
COPY --from=build /app/dist          ./dist
COPY package.json ./
USER 1000
EXPOSE 3000
CMD ["dist/server.js"]

Pin each base image by digest in production (node:24-bookworm@sha256:<digest>) so a re-pushed tag cannot change what you build. Distroless has no shell, so there is no HEALTHCHECK line: let the platform probe /healthz over HTTP instead. Prefer a HEALTHCHECK baked into the image? The create-daloy container templates ship an Alpine variant with tini as PID 1, STOPSIGNAL SIGTERM, a wget-based HEALTHCHECK, and digest-pinned bases.

Signed images + SBOM attestation

create-daloy --with-ci ships a deploy.yml that, after every successful push to GHCR, resolves the immutable @sha256:<digest>, signs the image with Sigstore Cosign (keyless / OIDC, no long-lived signing key), generates an SPDX SBOM for the image, and uploads it as a Cosign attestation (--type spdxjson). The job grants id-token: write alongside packages: write. The top-level workflow keeps permissions: {}. This closes the Aikido container-security checklist items for "Use Signed Images" and "Generate an SBOM." Verify any pulled image with:

bash
# Replace owner/repo and the digest with your values.
cosign verify ghcr.io/<owner>/<repo>@sha256:<digest> \
  --certificate-identity-regexp 'https://github\.com/<owner>/<repo>/\.github/workflows/deploy\.yml@.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

cosign verify-attestation \
  --type spdxjson \
  --certificate-identity-regexp 'https://github\.com/<owner>/<repo>/\.github/workflows/deploy\.yml@.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/<owner>/<repo>@sha256:<digest>

Kubernetes

The same image runs on Kubernetes (EKS, GKE, AKS, or your own cluster). Wire the probes and the shutdown timing so a rollout never drops requests:

yaml
# deployment.yaml (container spec excerpt)
spec:
  terminationGracePeriodSeconds: 30   # longer than preStop + shutdownTimeoutMs
  containers:
    - name: api
      image: ghcr.io/acme/api@sha256:<digest>
      ports: [{ containerPort: 3000 }]
      readinessProbe:
        httpGet: { path: /readyz, port: 3000 }
        periodSeconds: 5
      livenessProbe:
        httpGet: { path: /healthz, port: 3000 }
        periodSeconds: 10
      lifecycle:
        preStop:
          sleep: { seconds: 5 }       # let endpoints drop the pod first
      securityContext:
        runAsNonRoot: true
        readOnlyRootFilesystem: true
        allowPrivilegeEscalation: false
  • Shutdown order. Kubernetes runs preStop, then sends SIGTERM, then kills the pod when terminationGracePeriodSeconds runs out. The short preStop sleep gives Services and Ingress time to stop routing to the pod, then serve() drains for up to shutdownTimeoutMs. Keep preStop plus shutdownTimeoutMs under the grace period. The sleep action needs Kubernetes 1.30 or newer; distroless images have no sleep binary for an exec hook.
  • Readiness while draining. /readyz answers 503 as soon as draining starts, so the pod leaves the load balancer even if preStop is skipped.
  • Probe auth. In production app.healthcheck() refuses to register without a token or acknowledgeUnauthenticated: true. A kubelet probe can send a fixed Authorization header (httpHeaders), but that value sits in the pod spec. If you acknowledge unauthenticated probes instead, block /healthz and /readyz at your Ingress so they are not public.
  • Client IPs. One Ingress controller or cloud load balancer in front is behindProxy: { hops: 1 }. Add a hop for each extra layer, such as a CDN in front of the Ingress.

Graceful shutdown

The Node adapter installs SIGTERM/SIGINT handlers by default. DaloyJS stops accepting new requests (returning 503) and waits up to shutdownTimeoutMs for in-flight requests to drain.

ts
const { close } = serve(app, {
  shutdownTimeoutMs: 15_000,
  handleSignals: true,
});

// or trigger manually:
await app.shutdown(15_000);

Reverse proxy

If you sit behind nginx / Caddy / a load balancer / a Kubernetes Ingress / a PaaS edge (Railway, Render, Fly, Heroku, Cloud Run), declare the proxy posture so DaloyJS resolves the real client IP and stops refusing forwarded requests:

  • Set behindProxy: { hops: N } on new App({ ... }), where N is the number of trusted proxy hops in front of the app (a single edge proxy is 1. Cloudflare in front of one PaaS edge is 2). In production an unconfigured posture makes DaloyJS return 500 on the first request carrying an X-Forwarded-* header, so a misconfigured chain cannot feed spoofable client IPs to rateLimit(), request-id propagation, or audit logs. Use behindProxy: "none" when the app faces the public internet directly.
  • Once the posture is declared, rateLimit() keys on the resolved client IP automatically, no custom keyGenerator required.
  • X-Forwarded-For is read by default. Add header: "forwarded" for an RFC 7239 Forwarded proxy (Traefik, Caddy), or header: "cf-connecting-ip" behind Cloudflare, where the single-value header Cloudflare writes cannot be pre-populated by a client; see which header carries the client address.
  • Make the LB's idle timeout greater than DaloyJS's requestTimeoutMs.
  • Make the server's keepAliveTimeout greater than the LB's idle timeout, or the LB can reuse a socket Node just closed and return a 502. The Node adapter sets it to 5 seconds, which is shorter than most load balancers (AWS ALB defaults to 60 seconds), so raise it on the handle: serve(app).server.keepAliveTimeout = 65_000.

Edge / serverless REST APIs

DaloyJS can run on Vercel Functions, Cloudflare Workers (or self-hosted workerd), Netlify Functions, AWS Lambda, Azure Functions, and Fastly Compute because the core is Web-standard Request -> Response. Deno Deploy also works until it shuts down around April 2027; see Moving off Deno Deploy. Use these targets when you want per-request billing or a managed edge/serverless runtime instead of a long-lived Node process.

For a standalone Vercel REST API, create a single api/index.ts function and export the web-standard fetch handler, plus a vercel.json rewrite so DaloyJS routes at the site root (without it the root domain 404s):

ts
// api/index.ts
import { toFetchHandler } from "@daloyjs/core/vercel";
import { app } from "../src/server.js";

export default toFetchHandler(app);

// vercel.json
// { "rewrites": [{ "source": "/(.*)", "destination": "/api" }] }

See Vercel, Cloudflare Workers, Netlify, AWS Lambda, Azure Functions, and Fastly Compute for platform-specific entry files.

Edit this page on GitHub

Last updated