Infrastructure · Containers
How to Deploy Docker in Production: Builds, Compose, Hardening, and Health
Deploying Docker in production means more than running a container — it’s about building small, secure images and running them safely. You use multi-stage builds to keep build tools out of the final image, shrinking it from a gigabyte to a couple of hundred megabytes, and you run the container as a non-root user with a read-only filesystem and all Linux capabilities dropped, since Docker runs as root by default and a misconfigured container gives an attacker root-level access to the host. You keep secrets out of the image entirely, passing them at runtime through Docker secrets or a vault, and you add health checks so orchestrators know when the app inside is actually working, not just running. For a single host you wire it together with a Docker Compose file using network segmentation and resource limits, then put a reverse proxy in front for TLS and zero-downtime deploys.
Key takeaways
- Multi-stage builds shrink images. Keep build tools out of the runtime image — a gigabyte becomes a couple hundred megabytes.
- Never run as root. Docker defaults to root; a container escape then equals root on the host.
- Keep secrets out of images. They persist in layers — pass them at runtime via Docker secrets or a vault.
- Health checks reveal real state. A container can be “running” while the app inside is broken.
- Compose is single-host. Great for one server with a reverse proxy; reach for Kubernetes at multi-host scale.
Docker makes it trivially easy to run a container, which is exactly why production deployments so often go wrong — the defaults prioritise convenience over security, so a container that “works” can still run as root, leak secrets through its image layers, and fall over silently when the app inside dies. Deploying Docker properly is about closing those gaps deliberately. This guide walks through building small secure images, hardening the runtime, handling secrets and health correctly, and tying it together with Compose and a reverse proxy on a single host.
Why use multi-stage builds?
The first decision that shapes everything downstream is how you build your image, and multi-stage builds are the foundation of doing it well. A multi-stage build splits the Dockerfile into separate stages — one that installs build tools and compiles your application, and a final one that copies only the resulting artifacts into a slim runtime image. The payoff is dramatic: a naive single-stage build can produce a one-gigabyte image bloated with compilers and dev dependencies, while the multi-stage equivalent lands around 150 to 200 megabytes by shipping only what the app needs to run.
Smaller images aren’t just tidier — they deploy faster, cost less to store, and present a far smaller attack surface, since every package you don’t include is one fewer thing to patch or exploit. The base image choice compounds this: a full Debian image is over 100MB, its slim variant around 74MB, Alpine just 5MB, and a distroless image as little as 2MB with no shell at all. You pin base images by digest rather than a floating tag so builds are reproducible, and you order your Dockerfile to cache dependencies separately from source code — copying and installing dependencies before copying the frequently changing application code, so a code change doesn’t trigger a full dependency reinstall. The diagram shows the structure.
What does .dockerignore do?
A small file with an outsized impact on both build speed and security is .dockerignore, and it’s easy to skip until it bites you. When you run a build, Docker first sends the entire build context — your project directory — to the daemon, and a .dockerignore file excludes paths from that context the same way .gitignore excludes them from version control. Listing things like node_modules, the .git directory, local caches, test folders, the Dockerfile itself, and editor configuration keeps the context small, which makes builds noticeably faster and avoids invalidating layer caches unnecessarily.
The security angle is just as important. Without a .dockerignore, it’s dangerously easy to copy a .env file, local credentials, or other sensitive material into your image without realising it, since a broad COPY instruction will sweep in whatever is in the context. Excluding .env, secret files, and credentials in .dockerignore is a cheap and reliable guard against accidentally baking secrets into an image — a mistake that, as we’ll see, is hard to undo once it’s in the layers. Treating .dockerignore as mandatory rather than optional removes a whole category of build-context leaks before they can happen.
Running containers as non-root
The single highest-impact security change you can make is to stop running containers as root, because Docker’s default is genuinely dangerous. By default a container runs as root, and since it shares the host kernel, a container escape from a root process gives an attacker the equivalent of a root shell on the host — which is why the maxim “containers are not sandboxes” matters, and why container security incidents rose sharply year over year. The fix in the image is straightforward: create an unprivileged user in your Dockerfile with addgroup and adduser, copy files with the right ownership using COPY —chown, and switch to that user with the USER directive before the app runs.
There’s a useful pattern for applications that need root during setup but not at runtime: use a multi-stage build to do the privileged installation in the build stage, then run as a non-root user in the runtime stage. Beyond the container, you can go further at the host level with rootless Docker, which runs the Docker daemon and containers as a non-root user and significantly reduces the blast radius of any escape — it needs a recent Linux kernel and is the recommended approach for modern production deployments. Getting the user right is the foundation that the rest of the runtime hardening builds on.
Hardening the runtime
With a non-root user in place, several more runtime controls turn a default container into a hardened one, and they’re worth applying as a standard set. The table lays out the key hardening options and what each defends against.
| Option | Setting | Defends against |
|---|---|---|
| Non-root user | user: “1001:1001” | Host root access on escape |
| Read-only FS | read_only: true + tmpfs | Malware and backdoor writes |
| Drop capabilities | cap_drop: ALL | Packet sniffing, privilege abuse |
| No new privileges | no-new-privileges: true | Privilege escalation |
| Resource limits | cpus, memory | Runaway resource exhaustion |
Each addresses a real default weakness. A read-only root filesystem with a writable tmpfs for genuinely temporary files stops an attacker from writing malware or backdoors into the container; dropping all Linux capabilities and adding back only what’s needed removes dangerous defaults like the packet-sniffing NET_RAW capability; the no-new-privileges flag blocks privilege escalation; and CPU and memory limits keep one container from starving the host. Two more rules round out the set: never mount the Docker socket into a container, since that hands over control of the whole daemon, and set resource limits on every service — Compose has respected these on a normal up since version 2.17.
How do you scan images for vulnerabilities?
An image that’s small and hardened can still ship known vulnerabilities inherited from its base image or dependencies, which is why scanning belongs in every pipeline. The idea is to catch known CVEs before an image reaches production by running a scanner against it, and several good tools exist. Docker Scout integrates directly into the Docker workflow and reports CVEs; Trivy is a popular standalone scanner you can point at an image and filter to high and critical severities; and Snyk offers similar container scanning. These run quickly enough to sit in CI/CD as a gate, failing a build when a serious vulnerability appears.
A few complementary tools cover the rest of the surface. Hadolint lints your Dockerfile for bad practices, trufflehog scans an image for accidentally embedded secrets, and dive lets you explore an image layer by layer to see what actually ended up inside. The operational habit that matters most is rebuilding images regularly rather than letting them ossify, because base images receive security patches over time and an image built months ago carries whatever was vulnerable then. For production, hardened base images — distroless or curated hardened variants — start you from a smaller, better-maintained foundation. Scanning in CI plus regular rebuilds turns image security from a one-time check into an ongoing process.
How should you handle secrets?
Secrets are where a surprising number of deployments quietly fail, and the cardinal rule is that secrets must never live in the image. If you bake a password or API key into a Dockerfile — whether through an ENV instruction or by copying a secrets file — it persists in the image layers even if you delete it in a later layer, so anyone who pulls the image can extract it. This is the most common and most damaging Docker mistake, and it’s entirely avoidable.
The correct approach is to provide secrets only at runtime, never at build time. Docker secrets mount sensitive values into the container at /run/secrets and pair well with the convention of pointing an application at a file path — a DB_PASSWORD_FILE environment variable, for instance — rather than the secret value itself. For production, an external secret store like HashiCorp Vault is the stronger option, giving you central management and rotation. Whichever you use, keep the secret files themselves locked down with restrictive permissions, and in CI/CD rely on the platform’s secret store rather than committing values, since shell environment variables take precedence over a .env file and let pipelines inject secrets without touching tracked files.
Health checks and startup order
A container that’s “running” tells you nothing about whether the application inside it is actually working, which is the problem health checks solve. A health check is a command the container runs periodically — typically hitting a /health endpoint with curl — that reports whether the app is genuinely responsive. You define it either in the Dockerfile with a HEALTHCHECK instruction or in Compose with a healthcheck block, setting an interval, a timeout, a retry count, and a start period that gives the app a grace window to boot before failures count against it.
Health checks become especially powerful when they govern startup order. In Compose, depends_on with a condition lets a service wait for its dependencies to be genuinely ready: service_healthy waits for another service’s health check to pass, service_completed_successfully waits for a one-shot container like a database migration to exit cleanly, and service_started just waits for the container to launch. This solves a classic and maddening bug where an app starts faster than its database and crashes on boot — with a proper health condition, the app simply waits until the database reports healthy. The terminal shows these pieces together.
# Dockerfile (multi-stage, non-root, healthcheck) FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine AS runtime RUN addgroup -g 1001 -S app && adduser -u 1001 -S app -G app WORKDIR /app COPY —from=build —chown=app:app /app/dist ./dist USER app HEALTHCHECK —interval=30s —timeout=3s —retries=3 \ CMD wget -q -O- http://localhost:3000/health || exit 1 CMD [“node”, “dist/server.js”] # compose: harden + depend on a healthy db services: api: read_only: true security_opt: [ “no-new-privileges:true” ] cap_drop: [ “ALL” ] depends_on: { db: { condition: service_healthy } }
Logging and the init process
Two operational details often left until something breaks are logging and the init process, and getting them right early prevents real production headaches. On logging, the rule is to have your application write to stdout and stderr rather than to files inside the container, letting Docker capture the streams — and then to cap them, because the default json-file logging driver will happily grow until it fills the host’s disk. Setting a maximum size per log file and a maximum number of rotated files, commonly something like ten megabytes across three files, bounds that growth and removes a common cause of a server quietly running out of space.
The init process matters because of how Linux handles signals and zombie processes. When your application runs as PID 1 inside a container, it doesn’t get the usual init behaviour, so it may not forward termination signals correctly or reap the zombie processes that child processes leave behind — which can mean slow or unclean shutdowns. The fix is a tiny init system like tini, added to the image and set as the entrypoint, which sits as PID 1, properly forwards signals to your app, and reaps zombies. It’s a small addition that makes container shutdown clean and predictable, which matters every time you deploy or restart.
Tying it together with Compose
For a single-host deployment, Docker Compose is the tool that assembles your services into a running stack, and a few production settings matter. You set a restart policy — unless-stopped for most services so they recover from crashes, on-failure for one-shot jobs — define the resource limits discussed above, configure logging with a size and file cap so logs don’t fill the disk, and remove any debug ports. Network segmentation is a quiet but important defence: putting your proxy and API on a frontend network while keeping the database and cache on a backend network marked internal means those backend services can’t reach or be reached from the internet, limiting the damage if one is compromised.
It’s worth being honest about Compose’s limits. It’s an excellent fit for single-host, edge, and small-to-medium production deployments, but it offers no multi-host scheduling, autoscaling, or self-healing across machines — for that scale you move to Kubernetes or Swarm. On a single host, true zero-downtime deploys come from putting a reverse proxy like Nginx, Traefik, or Caddy in front of your services and combining it with health checks, so traffic only shifts to a new container once it’s healthy. You rebuild a single service without disturbing others using a targeted up command, and in CD you pull immutable images tagged with their commit SHA and bring them up. This kind of disciplined deployment pairs naturally with the monitoring our server monitoring guide describes, and for the host underneath it all, our dedicated servers in Toronto give containers a predictable, controllable foundation — while correct images, hardening, and health checks are what make the deployment trustworthy rather than merely running.