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.
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.
- Gate: .dockerignoreRepositoryClean clone, small build context.
- Gate: TestsBuild stageLockfile install, then compile.
- Gate: Size and scanRuntime stageOutput only, non-root user.
- Gate: HealthRunPORT, config and secrets injected.
- Serve trafficSame image in every environment.
The checklist
Work through these for every service. One repository can contain several services; each one gets its own image.
- Pin the base image. Use a specific version tag, such as
node:22.12-slim, notnode:latest. Pin a digest if you want builds to be fully reproducible. - Use a multi-stage build. Compile and install in one stage, copy only the output into a small runtime stage.
- Copy dependency manifests first. Install dependencies before copying the source so the layer cache survives code changes.
- Add a
.dockerignore. Keepnode_modules,.git,.env, build output and local data out of the build context. - Run as a non-root user. Most official images ship one; use it.
- Read the port from
PORT. Listen on0.0.0.0, notlocalhost, and take the port from the environment. - Expose a health endpoint. A cheap route such as
/healthzthat returns 200 only when the service can do its work. - Log to standard output. No log files inside the container.
- No secrets at build time. Never
COPY .env, never pass keys as build arguments. Secrets are injected when the container starts. - 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 ciinstalls exactly what the lockfile says. Commit the lockfile.- For Next.js, enable standalone output and copy
.next/standalone,.next/staticandpublicinstead of the whole directory. - Make sure the server reads
process.env.PORTand listens on0.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
uvicornbehindgunicornwith the uvicorn worker class, oruvicorndirectly for small services. PYTHONUNBUFFERED=1makes 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:
| Item | Value (MB) |
|---|---|
| Single stage, full base | 1,100 MB |
| Single stage, slim base | 420 MB |
| Multi-stage, slim base | 190 MB |
| Static binary, distroless | 24 MB |
- 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.
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.