Real-World Dockerfile Examples

By Vishvesh Patel · DevOps EngineerUpdated September 16, 2026

Complete production Dockerfiles for Node.js, Python, Go, React and Spring Boot. Every decision annotated, with the mistake each one avoids.

Complete, production-shaped Dockerfiles you can adapt. Every one of them follows the same five-part skeleton. Build stage, minimal runtime stage, manifests cached before source, non-root user, exec-form start command, and each section calls out the specific mistake that shape avoids.

Node.js API (TypeScript)

# syntax=docker/dockerfile:1

# ── Build ──────────────────────────────────────────────
FROM node:22-alpine AS build
WORKDIR /app

COPY package*.json tsconfig.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

COPY src ./src
RUN npm run build                      # → /app/dist

# ── Runtime ────────────────────────────────────────────
FROM node:22-alpine
WORKDIR /app

ENV NODE_ENV=production

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

COPY --from=build --chown=node:node /app/dist ./dist

LABEL org.opencontainers.image.source="https://github.com/org/api"

USER node
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "dist/server.js"]

The TypeScript compiler and every dev dependency stay in the build stage. The runtime stage installs production dependencies fresh rather than copying node_modules across. Copying them would bring the dev tree with it, which is the usual reason a "multi-stage" Node image is no smaller than a single-stage one.

COPY --chown rather than a trailing RUN chown -R: the latter rewrites every file into a new layer.

Python FastAPI

# syntax=docker/dockerfile:1

# ── Build wheels ───────────────────────────────────────
FROM python:3.12-slim AS build
RUN apt-get update \
 && apt-get install -y --no-install-recommends gcc libpq-dev \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

# ── Runtime ────────────────────────────────────────────
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app
RUN useradd -m -u 1000 appuser && chown appuser /app

COPY --from=build /wheels /wheels
RUN pip install --no-cache /wheels/* && rm -rf /wheels

COPY --chown=appuser:appuser . .
USER 1000
EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

Three details doing real work here.

-slim, not -alpine. PyPI's prebuilt wheels target glibc, so Alpine forces pip to compile nearly everything from source. Slower builds and frequently a larger image.

libpq-dev in the build stage, libpq5 at runtime. The -dev package carries headers and a compiler toolchain that the running application has no use for.

--host 0.0.0.0. Uvicorn defaults to 127.0.0.1, which inside a container means that container only. This is the single most common cause of a published port that still refuses connections.

PYTHONUNBUFFERED=1 matters more than it looks: without it, Python buffers stdout and your logs arrive in chunks, or not at all when the container is killed.

Go (static binary on scratch)

# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS build
RUN apk add --no-cache ca-certificates tzdata

WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download

COPY . .
ARG TARGETOS TARGETARCH
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -ldflags="-w -s" -o /out/server .

# ── Runtime ────────────────────────────────────────────
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/server /server

EXPOSE 8080
USER 1000
ENTRYPOINT ["/server"]

CGO_ENABLED=0 is not optional. A dynamically linked binary in scratch dies instantly with no such file or directory. The missing file being the dynamic linker, which is why the message names nothing helpful.

scratch has no CA certificates and no timezone database, so any HTTPS call fails and every timestamp is UTC until you copy both across.

-ldflags="-w -s" strips the symbol table and DWARF debug info, typically cutting the binary by around a quarter.

The $BUILDPLATFORM and TARGETARCH combination cross-compiles natively on the builder rather than running the whole build under emulation, which is both much faster and what prevents an exec format error when an arm64 Mac builds for amd64 servers.

React SPA behind Nginx

# ── Build ──────────────────────────────────────────────
FROM node:22-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
ARG VITE_API_URL=https://api.example.com
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build                      # → /app/dist

# ── Runtime ────────────────────────────────────────────
FROM nginx:1.27-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/app.conf
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 80
STOPSIGNAL SIGQUIT
CMD ["nginx", "-g", "daemon off;"]
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;   # client-side routing
    }

    location = /health {
        return 200 'OK';
        add_header Content-Type text/plain;
    }

    location ~* \.(js|css|png|jpg|svg|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

STOPSIGNAL SIGQUIT is the detail most nginx images miss. Nginx treats SIGTERM as a fast shutdown that cuts active connections, and SIGQUIT as a graceful drain. Without this line every deploy severs in-flight requests.

Build args are baked in, not configurable at runtime. VITE_API_URL is compiled into the JavaScript bundle, so one image cannot serve two environments. If you need that, serve a small /config.json that the app fetches at startup instead.

Spring Boot (layered JAR)

# syntax=docker/dockerfile:1

FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN --mount=type=cache,target=/root/.m2 ./mvnw dependency:go-offline

COPY src ./src
RUN --mount=type=cache,target=/root/.m2 ./mvnw package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract --destination extracted

# ── Runtime ────────────────────────────────────────────
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S -u 1000 -G spring spring

COPY --from=build --chown=spring:spring /app/extracted/dependencies/ ./
COPY --from=build --chown=spring:spring /app/extracted/spring-boot-loader/ ./
COPY --from=build --chown=spring:spring /app/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=spring:spring /app/extracted/application/ ./

USER 1000
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

JRE at runtime, JDK only to build. Shipping the JDK adds a compiler and hundreds of megabytes for no benefit.

layertools extract splits the fat JAR into layers that change at different rates. Dependencies rarely change and stay cached; your application classes are the last, tiny layer. Copying the whole JAR instead means every one-line code change re-pushes the entire thing.

Django + Gunicorn

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app
RUN useradd -m -u 1000 django && chown django /app

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY --chown=django:django . .
RUN python manage.py collectstatic --noinput

USER 1000
EXPOSE 8000

CMD ["gunicorn", "myproject.wsgi:application", \
     "--bind", "0.0.0.0:8000", \
     "--workers", "4", \
     "--timeout", "120"]

collectstatic runs at build time so the image is self-contained and container start stays fast. Note it needs settings that do not require a database connection. A DJANGO_SETTINGS_MODULE pointing at build-safe settings is the usual arrangement.

Exercise

A multi-stage build that saved nothing

A Node.js Dockerfile builds in one stage and then copies both /app/dist and /app/node_modules from it into the runtime stage. The final image is the same size as the old single-stage one. Why?

The shape they share

  1. Build stage with every tool needed to compile or bundle.
  2. Runtime stage on the smallest base that can actually run the output.
  3. Manifests copied and installed before source, so the expensive step stays cached.
  4. Non-root numeric user, with ownership set during COPY.
  5. Exec-form CMD or ENTRYPOINT, plus STOPSIGNAL where the server wants something other than SIGTERM.

To adapt one: change the base image to your pinned version, point the start command at your entry file, bind to 0.0.0.0 rather than localhost, and check whether your runtime needs a system library that only appeared in the build stage.

Exercise

Nginx drops connections on every deploy

An Nginx container serving a React SPA severs in-flight requests each time it is replaced, even though the app is static and the rollout is gradual. Which Dockerfile line fixes it?


Key takeaways

  • Do not copy node_modules between stages. It carries devDependencies and erases the saving.
  • Python belongs on -slim, and --host 0.0.0.0 is what makes a published port actually answer.
  • scratch needs CGO_ENABLED=0, CA certificates and timezone data copied in.
  • Nginx needs STOPSIGNAL SIGQUIT or every deploy cuts live connections.
  • Ship a JRE, not a JDK, and extract Spring Boot's layers so dependencies stay cached.
  • Build args are compiled in. One image per environment, or fetch config at runtime.
  • COPY --chown beats RUN chown -R: the latter duplicates every file into a new layer.

Frequently Asked Questions

What does a production-ready Dockerfile look like?

Five things, in every language: a build stage holding the compiler and dev dependencies, a runtime stage on the smallest base that can run the output, dependency manifests copied and installed before source so the expensive layer stays cached, a non-root numeric USER with ownership set via COPY --chown, and an exec-form CMD so the process receives SIGTERM. Add EXPOSE, OCI labels and a health check for tooling. Anything beyond that is tuning.

Why is my multi-stage Node.js image still large?

Almost certainly because node_modules is being copied from the build stage. That directory was produced by npm ci, which installs devDependencies alongside production ones because the TypeScript compiler needs them, so copying it forward brings the entire dev tree into production. Copy only the build output, and run npm ci --omit=dev separately in the runtime stage. Copying package*.json first there keeps that install cached too.

Should I use Alpine or slim for a Python Dockerfile?

-slim, in nearly every case. Alpine uses musl, and PyPI's prebuilt manylinux wheels target glibc, so pip install falls back to compiling from source. That turns a twenty-second install into several minutes, requires gcc and development headers in the image, and often produces a larger result than the slim variant. Alpine is an excellent choice for Go and Rust; for Python it usually costs on every axis you were trying to optimise.

How do I pass environment-specific configuration to a React Docker image?

A build arg such as VITE_API_URL is compiled into the JavaScript bundle, which means one image per environment and a rebuild for every change. Fine if your pipeline builds per environment anyway. If you want a single image promoted from staging to production, serve a small /config.json that Nginx can have mounted or generated at container start, and have the app fetch it before rendering. Runtime environment variables cannot reach an already-built bundle.

Why does my containerised app work locally but refuse connections in Docker?

The server is bound to 127.0.0.1, which inside a container means that container alone, so Docker's port mapping delivers traffic to an interface nothing is listening on. Bind 0.0.0.0 instead: --host 0.0.0.0 for Uvicorn, --bind 0.0.0.0:8000 for Gunicorn, app.listen(3000, '0.0.0.0') in Express. Confirm with docker exec <container> netstat -tlnp, which shows what is actually listening and on which address.

Why does my Go container exit immediately with "no such file or directory"?

The binary is dynamically linked and the file the kernel cannot find is the dynamic linker, not your program, which is why the error names nothing useful. Build with CGO_ENABLED=0 to produce a fully static binary. While you are there, remember that scratch also lacks TLS root certificates and timezone data, so copy /etc/ssl/certs/ca-certificates.crt and /usr/share/zoneinfo from the build stage, or use gcr.io/distroless/static, which ships both.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Docker Playground

Run the commands in a simulated terminal, no install needed.

When it breaks

Related Resources