How do I secure a container deployment?
Ship a current, minimal image, run the process as a non-root user, mount the root filesystem read-only, keep secrets out of layers, and scan the artifact in CI before you push it. Application rules (rate limits, bot detection, Shield) still belong in the handler. The container is the other half: it limits what an attacker can do after they get code execution.
Nothing here is 100% secure. Each control fails differently, so you stack them. A 2024 supply-chain incident is a useful historical check: the xz-utils backdoor (CVE-2024-3094) landed in liblzma and targeted sshd. Teams on rolling, inventoried images could confirm in minutes that they did not ship the tainted package and did not run sshd. Teams on stale fat images had a longer question.
Should containers run as non-root?
Yes. Docker runs as root unless you change it. If an attacker reaches child_process or a reverse shell, they inherit that user. Root inside the container can install miners, rewrite binaries, and (when a container-escape bug exists) start from a privileged uid on the host.
Set USER in the final stage. Distroless and Chainguard images already provide a nonroot user. Alpine does not; you create one.
This is a different control from rootless Docker or Podman on the host. USER limits what the process can do inside the container. Rootless runtimes limit what the container engine can do on the host. Use both if you run your own nodes. Managed platforms (Fly, Cloud Run, EKS) already constrain the host side; you still set USER.
Which base image is most secure?
The most secure image is the smallest current one that still runs your app. Size is a proxy for attack surface: fewer packages mean fewer CVEs and fewer binaries an attacker can exec. Compatibility is the constraint. Alpine uses musl; Node addons, some Go binaries, and DNS resolution have historically broken there. Distroless has no shell and no package manager. Wolfi (Chainguard) is a rolling glibc distro with an SBOM.
Use Node 22 (current LTS) on current Debian. Official Distroless Node images are Debian 13 (trixie). Do not copy older recipes that pin Node 20 on Debian 11 or 12.
| Base | Typical size | libc | Shell / pkg manager | Patch cadence | Trade-off |
|---|---|---|---|---|---|
Alpine ( | Smallest official Node image | musl | ash + apk | Regular official rebuilds | Small and familiar. musl breaks some native addons and is not an official Go target. |
Distroless ( | Small (Node + libc + libssl) | glibc | Neither (debug tag adds busybox) | Tracks Debian stable | No shell to abuse. Harder to debug. Not rolling, so packages wait on Debian. |
Wolfi (Chainguard | Small, glibc | glibc | None in production tags | Rolling, with SBOM | Fast patches and inventory. Confirm the tag and license for your registry. |
A full node:22 Debian image is the worst default for production: hundreds of packages you never call. node:22-bookworm-slim / node:22-trixie-slim is acceptable for a build stage. Do not run the slim image as the final runtime if you can use Distroless or Wolfi.
FROM node:22 AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY . .
FROM gcr.io/distroless/nodejs22-debian13:nonrootWORKDIR /appCOPY --from=build /app /appUSER nonrootEXPOSE 3000CMD ["index.js"]Alpine needs the user created in the final stage:
FROM node:22-alpineRUN addgroup -S app && adduser -S -G app nonrootWORKDIR /appCOPY --from=build /app /appUSER nonrootCMD ["node", "index.js"]Why use a read-only filesystem?
A read-only root stops an attacker from writing a second binary, dropping a reverse shell, or replacing your entrypoint after they get execution. Test it locally:
docker run --rm --read-only node:22-alpine mkdir /tmp/pwn# mkdir: can't create directory '/tmp/pwn': Read-only file systemIf the process needs scratch space (Next.js cache, image uploads, session files), mount a tmpfs on those paths or move the data to object storage and a database. In Kubernetes set securityContext.readOnlyRootFilesystem: true and add an emptyDir with medium: Memory for /tmp.
Read-only does not stop an in-memory payload. It removes persistence and tooling. Combine it with non-root and a distroless image so there is no apk, apt, or /bin/sh to install that tooling anyway.
How do I scan images and secrets in CI?
Do not bake secrets into layers. Load them at runtime from the platform: Fly secrets, AWS Secrets Manager, GCP Secret Manager. Then scan the image you just built, because a .env copied by COPY . . will still be in a layer.
TruffleHog can scan a saved image. Gitleaks and GitHub push protection catch secrets in git before the image exists. osv-scanner fails the build on known dependency CVEs. Secret scanning in Next.js builds has the Next.js-specific artifact path (.vercel/output and NEXT_PUBLIC_ leakage).
- name: Save image run: docker save -o /tmp/app.tar my-app:latest- name: Scan image for verified secrets run: > docker run --rm -v /tmp:/tmp trufflesecurity/trufflehog:latest docker --image file:///tmp/app.tar --fail --only-verified --no-updateRun the same Semgrep and Trunk rules locally and in CI. A pre-commit hook that CI does not enforce is optional.
How should I automate deployments?
Deploy from GitHub Actions (or the equivalent) with short-lived identity, not a long-lived cloud key in repository secrets.
- Workflow files are code. Review them.
- Environments hold per-stage secrets (prod, staging, region).
- Required checks and a human approval gate sit on production.
- GitHub's OIDC federation assumes an IAM role in AWS (or the Workload Identity equivalent) for the life of the job.
- Fly has an official deploy action and CLI. Deploying an Arcjet-protected app to Fly.io covers the
ARCJET_KEYsecret. Self-hosted PaaS setups such as Coolify need the same image rules plus a private admin network.
Container security checklist
Use this as a release gate. Skipping one row is not offset by passing another.
| Control | Pass condition |
|---|---|
| Current runtime | Node 22 (or current LTS), not Node 20 on Debian 11/12 |
| Minimal base | Distroless, Wolfi, or Alpine. Not a full Debian desktop image |
| Non-root | Final stage sets |
| Read-only root |
|
| No secrets in layers | Runtime secret store. TruffleHog on the saved image reports nothing verified |
| Locked dependencies | Lockfile committed. osv-scanner or equivalent is a required check |
| Signed, short-lived deploys | OIDC or platform identity. No static cloud keys in GitHub secrets |
| Request-path controls | Rate limits and bot rules in the handler, not only at the edge |
Frequently asked questions
Should containers run as non-root?
Yes. Docker defaults to root, so a reverse shell inherits root. Set USER in the final stage. Distroless and Chainguard images already provide nonroot. This is separate from rootless Docker on the host.
Which base image is most secure?
The smallest current image that still runs your app. Distroless (gcr.io/distroless/nodejs22-debian13) and Wolfi are glibc and minimal. Alpine is smaller but uses musl. Do not ship a full node:22 desktop image or Node 20 on Debian 11/12.
Why mention the xz backdoor?
As history (CVE-2024-3094, 2024). Teams on rolling, inventoried images confirmed quickly they did not ship the tainted liblzma and did not run sshd. It is not an active incident.
Does a read-only filesystem stop all post-exploit activity?
No. It stops writing binaries and tools. In-memory payloads still run. Combine it with non-root and a distroless image that has no shell or package manager.
What belongs on the container checklist?
Current LTS runtime, minimal base, non-root USER, read-only root with an explicit tmpfs, no secrets in layers, lockfile plus osv-scanner, OIDC deploys, and request-path rate limits and bot rules.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.