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.

Multi-stage buildBuild stage (~1GB)full image + compilersdev dependenciescompiles app → artifactsCOPY —from=buildartifacts onlyRuntime stage (~150MB)slim/distroless baseartifacts + runtime deps→ the shipped image
The build stage is discarded; only its artifacts travel into the small runtime image you actually deploy.

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.

Runtime hardening options and their purpose.
OptionSettingDefends against
Non-root useruser: “1001:1001”Host root access on escape
Read-only FSread_only: true + tmpfsMalware and backdoor writes
Drop capabilitiescap_drop: ALLPacket sniffing, privilege abuse
No new privilegesno-new-privileges: truePrivilege escalation
Resource limitscpus, memoryRunaway 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.

production-docker
# 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.

Frequently asked questions

How do I deploy a Docker application to production?
Build a small, secure image with a multi-stage build that keeps build tools out of the final runtime image, and run it as a non-root user with a read-only filesystem and dropped capabilities. Keep secrets out of the image entirely, passing them at runtime through Docker secrets or a vault, and add health checks so orchestrators know when the app is actually working. For a single host, assemble the services in a Docker Compose file with network segmentation, resource limits, and restart policies, then put a reverse proxy like Nginx or Traefik in front for TLS and zero-downtime deploys. Scan images for vulnerabilities in CI before shipping.
Why should containers not run as root?
Because Docker containers share the host kernel, so a process running as root inside a container that manages to escape gets the equivalent of a root shell on the host — containers are not sandboxes. Docker runs as root by default, which makes this a real and common risk; container security incidents have risen sharply year over year. The fix is to create an unprivileged user in your Dockerfile and switch to it with the USER directive before the app runs. For applications needing root only at build time, use a multi-stage build to install as root then run as non-root. Rootless Docker reduces the host-level risk further.
How do I handle secrets in Docker?
Never bake secrets into the image. A password or key added via ENV or copied into a Dockerfile persists in the image layers even if you delete it later, so anyone who pulls the image can extract it. Instead, provide secrets only at runtime: Docker secrets mount values into /run/secrets and pair well with pointing your app at a file path (like DB_PASSWORD_FILE) rather than the value itself. For production, an external store like HashiCorp Vault gives central management and rotation. Keep secret files locked down with restrictive permissions, and in CI/CD use the platform’s secret store rather than committing values.
What does a Docker health check do?
A health check is a command the container runs periodically — typically hitting a /health endpoint — that reports whether the application inside is genuinely working, since a container can be “running” while the app is broken and unresponsive. You define it in the Dockerfile with HEALTHCHECK or in Compose with a healthcheck block, setting an interval, timeout, retries, and a start period grace window. Health checks also govern startup order: in Compose, depends_on with condition service_healthy makes a service wait until its dependency is genuinely ready, solving the classic bug where an app starts before its database and crashes on boot.
Is Docker Compose suitable for production?
Yes, for single-host deployments, edge scenarios, and small-to-medium applications. For production use, set restart policies, define resource limits, configure logging with size caps, remove debug ports, use health checks, and apply network segmentation so backend services aren’t internet-reachable. What Compose doesn’t offer is multi-host scheduling, autoscaling, or self-healing across machines — for that scale you move to Kubernetes or Docker Swarm. On a single host, achieve zero-downtime deploys by putting a reverse proxy like Nginx or Traefik in front of your services combined with health checks, so traffic only shifts to a new container once it’s healthy.