How to Deploy a Docker Image: A Production Path That Does Not Break at 3am

Sean

Platform Writer

Jun 09, 2026
12 min read

To deploy a Docker image in production, you build it from a Dockerfile, push it to a registry, and run it on a host with environment variables, persistent storage, a health check, and a reverse proxy in front of it. The build, push, and run pattern is the same everywhere. The three details that decide whether the deploy holds up in production are the base image, the health check, and the run command. Get those right, and the rest is plumbing.

This post is the production path. Not the “docker run hello-world” version, not the Kubernetes deep dive, and not the hand-wave-y “deploy to the cloud” version. The path that holds up when the first real user hits it.

How to deploy a Docker image: a production path that does not break at 3am

Table of contents

The direct answer

Build, push, run.

# 1. Build the image
docker build -t myregistry/myapp:1.0.0 .

# 2. Push to a registry
docker push myregistry/myapp:1.0.0

# 3. Run it on the host
docker run -d \
  --name myapp \
  --restart unless-stopped \
  -p 8080:8080 \
  -e DATABASE_URL=$DATABASE_URL \
  -e SECRET_KEY=$SECRET_KEY \
  myregistry/myapp:1.0.0

That is the entire deploy in three commands. The reason production deploys feel complicated is that there are about twenty decisions hiding inside those three commands, and most of them are “pick the right default.” The rest of this post is the defaults, the decisions, and the things that go wrong when you skip them.

The Dockerfile that ships to production

A production-ready Dockerfile for a typical web service looks like this:

# syntax=docker/dockerfile:1.7

# --- Build stage ---
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# --- Runtime stage ---
FROM python:3.12-slim AS runtime
RUN useradd -m -u 10001 appuser
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
USER appuser

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PORT=8080

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz', timeout=3)"

CMD ["gunicorn", "main:app", "--bind", "0.0.0.0:8080", "--workers", "2", "--access-logfile", "-"]

Every line earns its place:

  • Multi-stage build. The build stage has the compiler toolchain. The runtime stage does not. The smaller runtime image is faster to push, faster to pull, and has a smaller attack surface.
  • python:3.12-slim. Slim is small enough to ship fast and complete enough to run a real Python service. Alpine is smaller and has more compatibility surprises than it is worth. Distroless is even smaller but harder to debug. For most services, slim is the right default.
  • Non-root user. USER appuser removes the most common container escape vector. The official Docker security guidance has been “run as non-root” for a decade; most teams still skip it.
  • PYTHONUNBUFFERED=1. Forces stdout/stderr to flush immediately. Without it, container logs buffer for 4KB and your “the deploy failed” message appears 30 seconds late.
  • HEALTHCHECK. The most underused directive in Dockerfile. It is what the orchestrator uses to decide whether the container is healthy. Without it, every container looks healthy even if the app is deadlocked.
  • CMD with a real production server. gunicorn for Python, uvicorn for FastAPI, puma for Rails, the actual server, not python main.py (which is fine for development and wrong for production).

For a Node service, the equivalent shape uses node:20-slim, a multi-stage build with the production npm ci --omit=dev, and a CMD ["node", "server.js"] that does not include nodemon or any dev-only tooling.

The base image and the user are the two details most teams skip. They are also the two details that decide whether the container is reasonable to ship.

Build, tag, push: the three commands

The build pipeline is the same on every host:

# Build
docker build -t registry.example.com/myorg/myapp:1.0.0 .

# Login to the registry
docker login registry.example.com

# Push
docker push registry.example.com/myorg/myapp:1.0.0

The tag is the version. The image is identified by the registry path and the tag. The format is <registry>/<org>/<repo>:<tag>. Common tag conventions:

  • Semver (1.0.0, 1.0.1): the standard. Pinned, reproducible, easy to roll back.
  • Git SHA (abc1234): the deployer-friendly version. Every commit has a unique image. Pairs with a CI system that builds on every merge.
  • Build number (build-142): the CI-built version. Useful when the CI system tracks deploys.
  • latest: the most recent tag pushed. Useful for local dev. Never use latest in production. It changes under you, and the rollback story is “push the old tag again,” which is not a story.

The best practice is to tag every release with the semver and the git SHA:

VERSION=$(git rev-parse --short HEAD)
docker build -t registry.example.com/myorg/myapp:1.0.0 \
             -t registry.example.com/myorg/myapp:$VERSION .
docker push registry.example.com/myorg/myapp:1.0.0
docker push registry.example.com/myorg/myapp:$VERSION

The semver is for humans. The git SHA is for the deploy system. The two-tag pattern means the rollback is one command: redeploy 1.0.0 if 1.0.1 is broken, or check out the commit and redeploy.

For most production teams, this build is in a CI pipeline (GitHub Actions, GitLab CI, Buildkite) that triggers on every push to main. The CI runs the build, tags the image, pushes to the registry, and triggers the deploy. The local docker build is for development and testing the Dockerfile; the CI build is what actually ships.

Run it: the production command

The docker run command on the production host is the most-misconfigured line in the entire pipeline. Here is the production version:

docker run -d \
  --name myapp \
  --restart unless-stopped \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /var/run \
  --cap-drop ALL \
  --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges \
  --memory 512m \
  --cpus 1.0 \
  -p 127.0.0.1:8080:8080 \
  -e DATABASE_URL="$DATABASE_URL" \
  -e SECRET_KEY="$SECRET_KEY" \
  myregistry/myapp:1.0.0

The flags worth understanding:

  • --restart unless-stopped: restart the container if it crashes, and on host reboot. The default is “do not restart,” which is the wrong default for production.
  • --read-only: make the container’s filesystem read-only. The app cannot write to its own filesystem, which means a misbehaving dependency cannot fill the disk. Combine with --tmpfs for the directories that need to be writable (/tmp, sometimes /var/run).
  • --cap-drop ALL --cap-add NET_BIND_SERVICE: drop all Linux capabilities, add only the one needed. The default capability set is broader than most apps need.
  • --security-opt no-new-privileges: prevent the container from gaining new privileges. The default is to allow setuid binaries to gain capabilities, which is the wrong default.
  • --memory 512m --cpus 1.0: set a memory and CPU limit. Without a limit, a single misbehaving container can exhaust the host’s resources.
  • -p 127.0.0.1:8080:8080: bind only to localhost. The reverse proxy listens on the public interface and forwards to the container. Never expose the container directly to the public internet without a proxy.

The --read-only and the capability drops are the security defaults. Most teams skip them because they break local development (where the app expects to write logs to /app/logs or similar). The right pattern is to make the app write logs to stdout (which Docker captures) and use --tmpfs for the few directories that need to be writable.

The resource limits are the operational defaults. A container without a memory limit will be OOM-killed by the kernel, which manifests as a “container randomly dies” bug that is impossible to debug. Set the limits explicitly, and the OOM-kill becomes a “memory exceeded the limit” event in the logs.

Environment variables and secrets

Environment variables are the standard way to pass configuration to a Docker container. The pattern is:

# Inline
docker run -e DATABASE_URL=postgres://... myapp:1.0.0

# From the host environment
docker run -e DATABASE_URL myapp:1.0.0

# From a file
docker run --env-file .env myapp:1.0.0

The third option, --env-file, is the right pattern for production. The .env file lives on the host, is owned by root, and is read once at container start. The application sees the variables as a regular environment.

The non-obvious details:

  • Never bake secrets into the image. If the image is pushed to a public registry, the secrets are public. Build with --no-cache and use build args only for non-sensitive values.
  • Never commit .env to git. The .env file goes in .gitignore. The template (.env.example) goes in git.
  • Use a secret manager in production. HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager. The secret manager hands the value to the container at start, and the application never sees the value in plaintext in the build pipeline.

For Docker Compose, the equivalent pattern is the environment: block in the service definition. For Kubernetes, it is the Secret resource mounted as an environment variable. The pattern is the same; the syntax differs.

For a managed platform, the secret management is usually built in. The platform’s dashboard lets you set environment variables, the values are encrypted at rest, and the application reads them as a regular environment. The platform handles the rotation story. For Python service deployment specifically, the RunxBuild Docker service docs cover the service definition, the environment variables, the persistent storage, and the health check.

Persistent storage: the part most guides skip

Containers are ephemeral. When a container restarts, the filesystem is gone. The data the container wrote to /app/data is gone with it.

The pattern for “I need to keep data across container restarts” is a Docker volume:

# Create a named volume
docker volume create myapp_data

# Run the container with the volume mounted
docker run -v myapp_data:/app/data myapp:1.0.0

The volume is managed by Docker, persists across container restarts, and survives docker rm. The data inside the volume is not part of the image, which is the right separation.

For multi-host deployments, a named volume is not enough. The volume lives on a single host, and a container on a different host cannot see it. The options:

  • Bind mount to a shared filesystem (NFS, EFS, Ceph). All hosts see the same data. Performance varies.
  • Object storage (S3, GCS, MinIO). The container reads and writes via the S3 API. Cheap, durable, eventual consistency. The right pattern for most file uploads.
  • Managed database (Postgres, MySQL, MongoDB). The container connects to a database service, not a local volume. The right pattern for relational data.

For a real application, the answer is almost always “use a managed database for structured data, object storage for files, and skip volumes entirely.” Volumes are for the narrow set of cases where the application genuinely needs local filesystem access (SQLite for a single-instance app, a local cache, a log directory).

For Docker Compose, the equivalent is the volumes: block in the service definition. For a managed platform, the platform’s persistent storage is the volume replacement. The pattern is the same: declare the volume, mount it into the container, treat it as durable.

Health checks that actually catch problems

A HEALTHCHECK in the Dockerfile is the difference between “the container is running” and “the container is doing its job.” Without a health check, every container looks healthy even if the application is deadlocked, the database connection is broken, or the upstream API is timing out.

The right health check:

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz', timeout=3)"

The application should expose a /healthz endpoint that:

  • Returns 200 when the app is fully started and able to serve traffic.
  • Returns 503 when the app is starting up but not ready.
  • Checks critical dependencies (database connection, cache connection) in addition to “the process is alive.”

The orchestrator uses the health check to decide when to send traffic to a new container (wait until it is healthy) and when to remove a container from the load balancer (mark unhealthy after 3 failed checks).

The most common mistake is making the health check too cheap. A curl localhost:8080/ that returns 200 because Nginx is responding does not tell you whether the app is alive. A health check that does not check the database does not tell you whether the app can serve a real request. The right health check is “the app is alive AND it can do its job.”

The reverse proxy, in plain language

A Docker container usually listens on an internal port. The reverse proxy (Nginx, Caddy, Traefik, or a cloud load balancer) sits in front of the container and forwards public traffic to it.

The pattern:

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The reverse proxy handles:

  • TLS termination (the HTTPS cert lives on the proxy, not the container).
  • HTTP/2 and HTTP/3 (the container speaks HTTP/1.1, the proxy upgrades).
  • Compression (gzip and brotli, applied to text responses).
  • Rate limiting (per-IP and per-route, applied at the edge).
  • Caching (static assets cached at the edge, reducing container load).
  • Web Application Firewall (basic protection against common attacks).

For most production deployments, the reverse proxy is either a managed load balancer (AWS ALB, GCP Load Balancer, Cloudflare) or a sidecar container (Caddy, Traefik). The application container stays simple: it listens on a port, it serves HTTP, it does not deal with TLS or rate limiting.

The “container is bound to 127.0.0.1” pattern from the docker run example is the most important detail. The container is not exposed to the public network. The reverse proxy is the only thing that talks to the public. If the container is on a different host, the reverse proxy forwards to the container’s internal IP. If the container is on the same host, the reverse proxy forwards to the container’s published port on 127.0.0.1.

Registries, and which one to pick

A registry is where the image lives between docker push and docker run. The common options:

  • Docker Hub: the default. Public images live here. Private repositories are available. The 2023 supply chain attacks (XZ, etc.) made some teams reconsider.
  • GitHub Container Registry (ghcr.io): free for public images, private repositories are available. Pairs well with GitHub Actions.
  • GitLab Container Registry: bundled with GitLab. Pairs with GitLab CI.
  • Amazon ECR, Google Artifact Registry, Azure Container Registry: the cloud-native options. IAM-integrated, fast pulls from the same cloud, pay per GB stored.
  • Self-hosted (Harbor, Distribution): the right answer when you have compliance requirements that forbid public registries.

For most teams, the registry that pairs with the CI system is the right choice. GitHub Actions + ghcr.io is a common combination. GitLab CI + the bundled registry is another. The cloud-native registry (ECR, GAR, ACR) is the right answer when the deploy target is in the same cloud.

The non-obvious detail: image pulls from a registry in a different region are slow and can be the dominant deploy time. A 500MB image pulled from us-east-1 to eu-west-1 takes longer than the actual deploy. The right pattern is to have the registry in the same region as the deploy target, or to use a registry that replicates.

Deploying to a managed platform

The docker build + docker push + docker run pattern works on bare metal and on a VM. For most production teams, the right answer is a managed platform that handles the orchestration, the load balancing, and the persistent storage.

The pattern on a managed platform:

  1. Connect the repository. The platform pulls the code from GitHub, GitLab, or Bitbucket.
  2. Point at the Dockerfile. The platform builds the image using the same Dockerfile.
  3. Configure the service. Environment variables, health check path, port, resource limits.
  4. Deploy. The platform builds, pushes to its own registry, and runs the container on a managed host.

The user-facing surface is a dashboard, a CLI, or an API. The user does not run docker run; the platform does. The Dockerfile, the environment variables, the health check, and the persistent storage are still the user’s responsibility. The platform handles the rest.

For a Docker service on a managed platform, the RunxBuild Docker service docs walk through the service definition, the Dockerfile detection, the environment variable setup, the persistent storage, and the health check. The pattern is the same one used on every PaaS; the surface is opinionated for the platform.

For cost modeling, a Docker service is a long-running instance plus a database plus (maybe) some storage. The RunxBuild hosting calculator shows the monthly bill for a Docker service at a specific instance size and traffic pattern.

Security defaults that matter

The five security defaults that decide whether the container is reasonable to ship:

  1. Run as non-root. USER appuser in the Dockerfile. Most container escape exploits rely on the process running as root.
  2. Read-only filesystem. --read-only at runtime. The container cannot modify its own filesystem, which means a misbehaving dependency cannot persist.
  3. Drop capabilities. --cap-drop ALL --cap-add NET_BIND_SERVICE. The default capability set is broader than most apps need.
  4. No new privileges. --security-opt no-new-privileges. Prevents setuid binaries from gaining capabilities.
  5. Pin base image versions. python:3.12-slim, not python:latest. A pinned base image is reproducible; a floating tag is a security incident waiting to happen.

The non-obvious one is #5. Most teams use python:3 or node:20 and never think about it. The base image changes, the build changes, the security posture changes, and nobody notices until the CVE lands. Pin the base image. Use a digest (python:3.12-slim@sha256:...) for the strongest guarantee, or pin to a specific minor version for a balance of reproducibility and maintenance.

FAQ

What is the difference between docker run and docker start?

docker run creates a new container from an image and starts it. docker start starts an existing container that was previously created and stopped. For a one-off, docker run is fine. For a service that restarts on crash, you want docker run with --restart unless-stopped, or a process manager (systemd, supervisord) that does docker start on a stopped container.

Should I use Docker Compose for production?

Docker Compose is great for development and small deployments (one host, a handful of services). For a real production deployment with multiple hosts, load balancing, and rolling deploys, you want a managed platform or Kubernetes. Docker Compose does not have a story for “the host died, restart the container on a different host.”

What is the difference between an image and a container?

An image is the build artifact. It is a read-only filesystem plus metadata. A container is a running instance of an image. The same image can be the source of many containers, each with its own writable layer, environment variables, and network namespace.

Can I run multiple processes in a single container?

Technically yes. Operationally no. The right pattern is one process per container, with a shared network namespace if the processes need to talk to each other. Running multiple processes in a single container makes health checks ambiguous, log aggregation harder, and rolling restarts impossible.

How do I update a running container?

Pull the new image, stop the old container, start a new container from the new image. The pattern:

docker pull registry.example.com/myorg/myapp:1.0.1
docker stop myapp
docker rm myapp
docker run -d --name myapp [same flags as before] registry.example.com/myorg/myapp:1.0.1

Most managed platforms handle this with a rolling deploy: they start the new container, wait for the health check to pass, then stop the old one. For a single-host setup, a small downtime window is acceptable. For zero-downtime, a load balancer and two containers are the right pattern.

How do I roll back a bad deploy?

Redeploy the previous image tag:

docker pull registry.example.com/myorg/myapp:1.0.0
docker stop myapp
docker rm myapp
docker run -d --name myapp [same flags] registry.example.com/myorg/myapp:1.0.0

This is why the two-tag pattern (semver + git SHA) matters. The rollback is one command, and the previous version is guaranteed to be in the registry. Without a pinned tag, the rollback is “find the old image somewhere,” which is the worst kind of incident.

What is the smallest base image I should use?

For most services, python:3.12-slim or node:20-slim is the right default. Alpine is smaller but has more compatibility surprises (musl vs glibc, missing compiler toolchain). Distroless is even smaller but harder to debug. The “smallest possible” image is usually a worse trade-off than it looks.

Should I use Docker for a simple static site?

Probably not. A static site has no runtime, no process, no environment variables. The deploy is “upload the files to a CDN.” A Docker container is overhead. The right pattern is a static site on a CDN (Cloudflare, Fastly, Vercel) with a serverless function for the parts that need server-side code.

How do I handle logs in production?

Write to stdout. Docker captures stdout, and the platform’s log aggregation picks it up from there. Writing to a file inside the container is the wrong pattern: the file is lost on container restart, and the platform cannot see it. For Python, set PYTHONUNBUFFERED=1 and use a structured logger that writes to stdout. For Node, the same pattern with console.log or a logger like pino.

How do I deploy a Docker image without Docker installed?

Use a managed platform. The platform builds the image from a Dockerfile, runs the container, and exposes the logs. You do not need Docker on your local machine. The Dockerfile is the contract; the platform handles the rest.