‹ All guides

Build

Containerize any app: a practical Dockerfile checklist

Turn a working repository into a reproducible image. Short Dockerfiles for Node, Python and Go, and the ten rules that make them production-worthy.

Cover illustration for Containerize any app: a practical Dockerfile checklistBuildKapten guide · aa09

What an image is for

A container image is the unit you ship. It should contain your application, its runtime and its dependencies, and nothing that changes between environments. The same image runs in staging and production; configuration and secrets arrive at runtime.

If you get that one idea right, most of the rest of this guide follows from it.

  1. Repository
    Clean clone, small build context.
    Gate: .dockerignore
  2. Build stage
    Lockfile install, then compile.
    Gate: Tests
  3. Runtime stage
    Output only, non-root user.
    Gate: Size and scan
  4. Run
    PORT, config and secrets injected.
    Gate: Health
  5. Serve traffic
    Same image in every environment.
Figure 1. One image, built once, promoted unchanged. Configuration and secrets only arrive when the container starts.

The checklist

Work through these for every service. One repository can contain several services; each one gets its own image.

  1. Pin the base image. Use a specific version tag, such as node:22.12-slim, not node:latest. Pin a digest if you want builds to be fully reproducible.
  2. Use a multi-stage build. Compile and install in one stage, copy only the output into a small runtime stage.
  3. Copy dependency manifests first. Install dependencies before copying the source so the layer cache survives code changes.
  4. Add a .dockerignore. Keep node_modules, .git, .env, build output and local data out of the build context.
  5. Run as a non-root user. Most official images ship one; use it.
  6. Read the port from PORT. Listen on 0.0.0.0, not localhost, and take the port from the environment.
  7. Expose a health endpoint. A cheap route such as /healthz that returns 200 only when the service can do its work.
  8. Log to standard output. No log files inside the container.
  9. No secrets at build time. Never COPY .env, never pass keys as build arguments. Secrets are injected when the container starts.
  10. One process per container. A web server and a background worker are two containers, built from the same or different images.

Node.js

# syntax=docker/dockerfile:1
FROM node:22.12-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22.12-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build --chown=node:node /app ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Notes:

  • npm ci installs exactly what the lockfile says. Commit the lockfile.
  • For Next.js, enable standalone output and copy .next/standalone, .next/static and public instead of the whole directory.
  • Make sure the server reads process.env.PORT and listens on 0.0.0.0.

Python

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
ENV PATH="/venv/bin:$PATH" PYTHONUNBUFFERED=1
COPY --from=build /venv /venv
COPY . .
RUN useradd --create-home app
USER app
EXPOSE 8000
CMD ["sh", "-c", "gunicorn app:app --bind 0.0.0.0:${PORT:-8000}"]

Notes:

  • Pin versions in requirements.txt, or use a lockfile from your package manager.
  • For FastAPI, run uvicorn behind gunicorn with the uvicorn worker class, or uvicorn directly for small services.
  • PYTHONUNBUFFERED=1 makes logs appear immediately.

Go

# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Notes:

  • A static Go binary on a distroless image is a very small, very boring runtime, which is the goal.
  • Distroless images have no shell. Debug with logs and a health endpoint, not with exec.

A .dockerignore to start from

.git
.env
.env.*
node_modules
dist
build
.next
__pycache__
*.pyc
.venv
coverage
*.log
.DS_Store

Add anything else that is local-only: test fixtures with real data, editor folders, large media.

The health endpoint

Return 200 when the service can serve a request, and an error when it cannot. For an API that depends on a database, check the connection with a cheap query. Keep it fast and unauthenticated, and do not expose internal details in the response.

Distinguish two questions if your platform supports it:

  • Liveness: is the process stuck? If so, restart it.
  • Readiness: can it take traffic right now? If not, stop sending it requests.

Rollouts are gated on readiness, so a new version only receives traffic once it can handle it.

Migrations are not a start command

It is tempting to run database migrations when the container starts. With more than one replica, several containers will try at once; with a failing migration, the service will crash-loop. Run migrations as a separate, explicit step before the release. The rollbacks guide explains how to keep them compatible with the previous version.

Check your image

Before you ship:

ItemValue (MB)
Single stage, full base1,100 MB
Single stage, slim base420 MB
Multi-stage, slim base190 MB
Static binary, distroless24 MB
Figure 2. Image size of one small API under four Dockerfile strategies. Build tools left in the runtime image are usually the difference.Illustrative · sample data
  • Build it from a clean clone, not your working directory.
  • Run it with only the environment variables production will have.
  • Hit the health endpoint and one real route.
  • Check its size. Hundreds of megabytes for a small API usually means build tools or dev dependencies made it into the runtime stage.
  • Search it for secrets. If a key ever made it into a layer, rotate the key: deleting the file in a later layer does not remove it.

How Kapten helps

When you ask Kapten to deploy a repository, the Navigator identifies the services, runtimes and ports, and the Shipwright writes or reviews a Dockerfile for each one following this checklist. Changes arrive as a pull request, never as a push to your default branch. When a build fails, the Shipwright explains the failure in terms of your code rather than handing you the raw log.

$ containerize this repo for production
navigator: Found 2 services: web (Node 22, port 3000) and worker (Node 22, no port).
web listens on localhost; will switch to 0.0.0.0 and read PORT.
shipwright: Multi-stage Dockerfile per service, non-root user, /healthz on web.
adds .dockerignore · .env stays out of the build context
Pull request opened: build: containerize web and worker
Figure 3. The Navigator reads the repository; the Shipwright proposes Dockerfiles as a pull request.Illustrative · sample session

See the Ship your first app workflow for the full sequence, or connect Kapten to your assistant to try it on your own repository during early access.

Build with Kapten

Start with one service.
Build from there.

Tell us what you want to deploy. We review each request and follow up about fit and onboarding on your preferred cloud.

Free during early access. No card required. Cloud usage billed separately.