Skip to content
New: managed cloud and dedicated servers — with dedicated support on every plan.See plans
Inventure Technologies

Docker to production: shipping containerised apps safely

How to deploy Docker to production safely: multi-stage builds, non-root users, healthchecks, image scanning, secrets, and a correct Node.js example.

IInventure Engineering Team8 min read
On this page

Getting a container to run on your laptop is easy; getting it to run safely in production takes a specific set of practices. To deploy Docker to production safely you need a slim, non-root image, a healthcheck, no secrets baked into layers, and a tagging scheme you can actually trace back to source. This post covers each of those, plus a correct Node.js Dockerfile and a Compose file with a reverse proxy you can adapt directly.

For where this fits into a wider DevOps setup, see the pillar guide: DevOps as a service: what it is, what it costs, and when it beats hiring.

Use multi-stage builds#

A multi-stage build uses more than one FROM instruction in a single Dockerfile, naming each stage with AS and copying only the specific files you need from one stage into the next with COPY --from=. This means your build tools, dev dependencies and source maps never reach the image that actually runs in production — only the compiled output does. Smaller images pull faster, start faster, and have a smaller surface for a vulnerability scanner to flag.

Choose a small, pinned base image#

Docker's own Dockerfile best practices recommend pinning image versions rather than floating tags like latest, so a rebuild next month uses the same base you tested against, not whatever latest has since become. Alpine or slim variants keep the attached surface area small; alpine images may need extra build-stage packages for native modules, which is exactly what a multi-stage build lets you discard afterwards. For Node.js, pin to the current Active LTS line — Node 24 as at September 2026 — with node:24-alpine, rather than an unpinned major version.

Run as a non-root user#

Docker's guidance is direct: "if a service can run without privileges, use USER to change to a non-root user." The official Node.js images already ship a built-in unprivileged node user, so you don't need to create one yourself — just switch to it with USER node before your CMD, as shown in the Node.js Docker best practices guide. If a container is ever compromised, running as a non-root user limits what the attacker can do with that access.

Add a healthcheck#

A HEALTHCHECK instruction tells Docker (and anything reading its status, including a load balancer or orchestrator) whether your app is actually working, not just running. Set explicit values rather than relying on defaults:

dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"

start-period matters for anything with a slower boot — failures during that window don't count against your retry limit, avoiding a container being killed while it's still starting up.

Use a .dockerignore file#

A .dockerignore file excludes files from the build context using glob patterns, the same idea as .gitignore. At minimum, exclude node_modules, .git, .env* and any local build output — this keeps secrets and irrelevant files out of the image entirely, and makes builds faster by shrinking what gets sent to the Docker daemon in the first place.

Scan images for known vulnerabilities#

Before an image reaches production, scan it for known CVEs in its OS packages and dependencies. Docker Scout builds a software bill of materials for an image and checks it against vulnerability databases, and re-evaluates it as new CVEs are published without needing a fresh scan each time — useful since an image that was clean on the day you built it can have a new CVE disclosed against it a week later. Open-source scanners such as Trivy do a similar job if you'd rather not depend on Docker's own tooling. Either way, scanning once before the first release isn't enough — build it into your pipeline so every image is checked on every push, and re-check what's already deployed on a regular schedule, since the image itself doesn't change but the vulnerability landscape around it does. Decide upfront what a "failed" scan means for your pipeline: blocking the build outright for anything critical is safer, but a lower severity finding might just create a ticket rather than stop a release.

Tag images properly#

Never deploy latest to production — it doesn't identify a specific build, so when something breaks you can't be certain which code is actually running, and a later rebuild elsewhere could silently pull a different image. Tag with a git commit SHA or a semantic version (myapp:2.3.1 or myapp:a1b2c3d) so every running container traces back to an exact, reviewable commit.

Handle secrets correctly#

Never COPY a file containing real secrets into an image, and never pass secrets as build arguments — both get baked into the image's layer history, readable by anyone who can pull or inspect it, even after you "remove" the file in a later layer. Supply secrets at container start instead: environment variables from your CI/CD system or Compose's env_file, or values pulled from a secrets manager at runtime.

If a build genuinely needs a credential — pulling a package from a private registry, for example — BuildKit's secret mounts keep it out of the final image and its history:

dockerfile
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci

The secret is only available for that one RUN step and is never written into a layer, unlike an ARG or ENV holding the same value.

Send logs to stdout and stderr#

Write application logs to standard output and standard error rather than to a file inside the container. Docker (and anything downstream — a log driver, a shipping agent) captures stdout/stderr automatically; logs written to the container's own filesystem disappear when the container is removed, which is exactly when you're most likely to need them.

Set resource limits#

An unbounded container can consume all the memory or CPU on a host, starving everything else running alongside it. In a Compose file (outside Swarm mode), the widely supported way to bound this is the top-level mem_limit and cpus keys, covered in Docker's Compose deploy reference alongside the Swarm-specific deploy.resources block. Set limits based on what the app actually needs under load, not a guess — an OOM-killed container restarting in a loop is easier to diagnose when you know the limit was intentional.

Compose vs orchestrators on a single server#

Docker Compose, running one or two well-configured servers behind a reverse proxy, is enough for a large share of production workloads — it gives you multi-container setups (app, database, proxy), restart policies and resource limits without the operational overhead of running a cluster. Reach for an orchestrator like Kubernetes once you need things Compose genuinely can't do alone: automatic scheduling across many nodes, self-healing across a large number of services, or a big enough team that a shared platform pays for itself. See do you really need Kubernetes? for a fuller decision guide, and zero-downtime deployments for how to release safely on either setup.

A production Dockerfile for Node.js#

dockerfile
# syntax=docker/dockerfile:1

# ---- Build stage ----
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- Production stage ----
FROM node:24-alpine AS production
ENV NODE_ENV=production
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist

USER node

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"

EXPOSE 3000
CMD ["node", "dist/server.js"]

This copies package.json and the lockfile before the rest of the source so the dependency-install layer is only rebuilt when dependencies actually change, uses npm ci for a reproducible install, and keeps the production stage free of build tools and dev dependencies via --omit=dev. Node.js also isn't designed to run as PID 1 — it won't forward signals like SIGTERM correctly on its own — so run the container with an init process (Compose's init: true, shown below, or docker run --init) rather than relying on the default.

A docker-compose.yml with a reverse proxy#

yaml
services:
  app:
    build: .
    image: myapp:2.3.1
    restart: unless-stopped
    init: true
    env_file:
      - .env.production
    expose:
      - "3000"
    mem_limit: 512m
    cpus: 1.0
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
      interval: 30s
      timeout: 5s
      retries: 3

  proxy:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app
nginx
upstream app_upstream {
    server app:3000;
}

server {
    listen 80;

    location / {
        proxy_pass http://app_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

The app container only exposes its port internally; the proxy is the one thing listening on the host's port 80, which keeps your application from being reachable directly and gives you one place to add TLS termination or rate limiting later.

The checklist to deploy Docker to production safely#

Before an image goes anywhere near production, it's worth running down a short list rather than trusting memory:

  • Multi-stage build, so build tools and dev dependencies never reach the final image.
  • Base image pinned to a specific version, never latest.
  • Container runs as a non-root user.
  • HEALTHCHECK set with explicit interval, timeout and start-period.
  • .dockerignore excludes secrets, node_modules, .git and local build output.
  • Image scanned for known vulnerabilities before release, and rescanned on a schedule afterwards.
  • Image tagged with a git SHA or semantic version, never deployed as latest.
  • Secrets supplied at runtime or via a BuildKit secret mount, never baked into a layer.
  • Logs written to stdout/stderr, not to the container's own filesystem.
  • Memory and CPU limits set explicitly, based on real usage under load.
  • An init process (--init or Compose's init: true) handles signals correctly.

What to do next#

If you're not sure your current setup covers scanning, secrets and resource limits consistently across every service you run, that's usually a sign it's time for a second pair of eyes rather than another manual fix. Managed DevOps covers container and deployment hardening as part of ongoing support, and CI/CD automation covers building and scanning images automatically on every push.

Frequently asked questions

Should I run my database in Docker in production?

You can, but many teams prefer a managed database service or a dedicated server for stateful data, keeping containers for stateless application code. If you do containerise a database, plan storage with a persistent volume and back it up the same way you would a non-containerised database — containers don't change your backup obligations.

Why shouldn't I use the latest tag in production?

Because it doesn't identify a specific build. If something breaks, you can't tell which code is actually running, and a rebuild elsewhere could silently pull a different image than the one you tested. Tag images with a git commit SHA or semantic version instead, so every deployment is traceable and reproducible.

Do I need Kubernetes to run Docker in production safely?

No. Docker Compose on one or two well-configured servers, behind a reverse proxy, safely runs many production workloads. Kubernetes solves problems — multi-node scheduling, self-healing across many services — that only show up at a certain scale. See do you really need Kubernetes? for how to tell which situation you're in.

How do I pass secrets to a container without baking them into the image?

Use environment variables supplied at container start (via your orchestrator, Compose's env_file, or your CI/CD system), or mount them from a secrets manager. Never COPY a file containing real secrets into an image layer or pass them as build arguments that get baked into image history.

Sources

  1. Multi-stage builds — Docker Docs — accessed 18 September 2026
  2. Best practices for writing Dockerfiles — Docker Docs — accessed 18 September 2026
  3. Dockerfile reference (HEALTHCHECK) — Docker Docs — accessed 18 September 2026
  4. Build context (.dockerignore files) — Docker Docs — accessed 18 September 2026
  5. Docker Scout — Docker Docs — accessed 18 September 2026
  6. Best Practices — nodejs/docker-node — accessed 18 September 2026
  7. Deploy support in Compose (resources) — Docker Docs — accessed 18 September 2026
  8. Node.js Releases (LTS schedule) — Node.js — accessed 18 September 2026

Facts in this article were last checked on 18 September 2026.

I

Inventure Engineering Team

Engineers at Inventure Technologies who build, host and run software for clients in Nepal and Australia. We write about what we do every day.

Keep reading

Want engineers who handle this for you?

We build, host and run software for teams in Nepal and Australia — with dedicated support on every plan.