Docker Container Builder
Generates production-ready, optimized Dockerfiles with multi-stage builds, caching, and tagging for any application.
Example run
Generated by Claude Opus 5.5 on September 23, 2026
Inputs used
- Application name
- invoice-sync-api
- Language and version
- Node.js 20.11.1 LTS (using the official 20-bookworm toolchain)
- Build tool, e.g. npm, pip, maven, go modules
- npm 10 (package-lock.json committed, npm ci for reproducible installs)
- Exposed port
- 3000 (HTTP API), plus 9229 only in dev for the Node inspector (not exposed in prod)
- Special requirements, e.g. system libs, env vars, secrets
- The app is an Express 4 REST service that syncs invoices between our billing DB and a third-party accounting API. It uses the `sharp` package for thumbnailing attached PDFs, which needs libvips (>=8.14) and its build headers (build-essential, python3) present at `npm install` time but NOT in the final runtime image — sharp ships prebuilt binaries for linux-x64 so only the shared libvips runtime lib is needed at runtime. Environment variables required at container start: - DATABASE_URL (Postgres 15 connection string, e.g. postgres://user:pass@db:5432/invoices) - REDIS_URL (used for job queue caching, e.g. redis://redis:6379/0) - JWT_SECRET (32+ char secret, injected via orchestrator secret store, never baked into the image) - STRIPE_API_KEY (injected the same way, used to reconcile invoice payments) - NODE_ENV=production - TZ=UTC (all invoice timestamps must be stored/logged in UTC) - LOG_LEVEL=info The service writes temporary PDF renders to /app/tmp, which must be writable by the non-root runtime user and ideally mounted as a tmpfs/volume in production. It exposes a liveness/readiness endpoint at GET /healthz returning 200 with JSON `{"status":"ok"}`. It's typically run behind an nginx reverse proxy, so `app.set('trust proxy', 1)` is already set in code — no extra flag needed in the container. No native compiler should remain in the final image; all devDependencies (jest, eslint, nodemon, supertest) must be excluded from the production node_modules.
- Registry / tag convention
- Push to GitHub Container Registry as ghcr.io/vongu3/invoice-sync-api. Tag with semver from package.json (e.g. invoice-sync-api:1.4.2), also push invoice-sync-api:1.4 and invoice-sync-api:1 rolling tags, invoice-sync-api:latest for the current main branch build, and invoice-sync-api:sha-<short-git-sha> for every CI build for traceability.
invoice-sync-api: Production Docker Setup
The spec was complete enough to build this without stopping to ask. One point in it does need your decision, though, so I made a default choice and flagged it.
⚠️ Read first: the sharp/libvips point
The spec says sharp's prebuilt linux-x64 binary needs a system libvips runtime library. That isn't how sharp's prebuilt binaries work. Since sharp 0.33, the prebuilt package includes its own libvips (via @img/sharp-libvips-linux-x64, about 17 MB). Earlier versions did the same through vendor/. A system libvips is only used if sharp is compiled from source against it.
So there are two consistent setups, and I exposed both through a build arg:
SHARP_LIBVIPS |
Builder installs | Runtime installs | Tradeoff |
|---|---|---|---|
bundled (default) |
build-essential, python3 | nothing extra | Smallest image, uses sharp's tested libvips build |
system |
+ libvips-dev (Debian 12 ships 8.14.1, which meets your ≥8.14 requirement) |
libvips42 |
Uses your distro-patched libvips, but adds roughly 150–200 MB of shared libraries. Requires node-addon-api and node-gyp in your dependencies. |
I defaulted to bundled because you asked for the smallest image. If you need system libvips (for example, for a compliance or patching policy), pass --build-arg SHARP_LIBVIPS=system.
1. Dockerfile
Rationale: There are four stages. The heavy node:20.11.1-bookworm toolchain installs production-only node_modules with compilers available. The runtime stage then copies just those modules and the source onto bookworm-slim, with no compiler, npm, yarn, or devDependencies. Optional dev and test targets share the same toolchain base, so local development (with the inspector on 9229) and CI tests never affect the production image. Application code and node_modules are owned by root and read-only to the app user; only /app/tmp is writable.
Base image choices:
node:20.11.1-bookworm(build stages): the official full toolchain, which already includes gcc, make, and python3, and uses the same Debian 12 glibc as the runtime. This keeps native binaries ABI-compatible.node:20.11.1-bookworm-slim(runtime): the smallest official image that pins the exact Node patch version and uses glibc, which sharp's prebuilt binary requires.- Why not distroless:
gcr.io/distroless/nodejs20can't be pinned to 20.11.1 by tag, and it can'tapt install libvips42if you picksystemmode. - Why not alpine: musl would need sharp's separate linuxmusl binaries and wouldn't match the glibc build stage.
- Why not distroless:
# syntax=docker/dockerfile:1.7
# ^ Enables BuildKit features used below (RUN --mount cache mounts).
############################################################
# Global build args (must be declared before the first FROM
# to be usable in FROM lines; re-declared inside stages).
############################################################
ARG NODE_VERSION=20.11.1
ARG DEBIAN_RELEASE=bookworm
# bundled = use sharp's prebuilt binary with its own libvips (smallest)
# system = compile sharp against Debian's libvips 8.14 (needs libvips42 at runtime)
ARG SHARP_LIBVIPS=bundled
############################################################
# Stage 1: build-base, full toolchain for native modules
############################################################
# For fully immutable builds, append @sha256:<digest> to both FROM lines
# (get it with: docker buildx imagetools inspect node:20.11.1-bookworm).
FROM node:${NODE_VERSION}-${DEBIAN_RELEASE} AS build-base
ARG SHARP_LIBVIPS
WORKDIR /app
# The apt cache mounts keep downloaded .debs out of the image and speed up rebuilds.
# The official image deletes the apt cache after every install via docker-clean;
# removing that hook lets the cache mount actually persist between builds.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
python3 \
$( [ "$SHARP_LIBVIPS" = "system" ] && echo libvips-dev )
############################################################
# Stage 2: prod-deps, production-only node_modules
############################################################
FROM build-base AS prod-deps
ARG SHARP_LIBVIPS
# Copy ONLY the manifests first. This layer (and the expensive npm ci below)
# is reused from cache until package.json or package-lock.json changes.
COPY package.json package-lock.json ./
# --omit=dev -> excludes jest, eslint, nodemon, supertest, etc.
# SHARP_* env vars -> make sharp's install behavior explicit, not auto-detected
# The final require('sharp') fails the BUILD (not the deploy) if the native
# binding is broken.
RUN --mount=type=cache,target=/root/.npm \
if [ "$SHARP_LIBVIPS" = "system" ]; then \
export SHARP_FORCE_GLOBAL_LIBVIPS=1; \
else \
export SHARP_IGNORE_GLOBAL_LIBVIPS=1; \
fi \
&& npm ci --omit=dev --no-audit --no-fund \
&& node -e "const s=require('sharp'); console.log('sharp OK, libvips', s.versions.vips)"
############################################################
# Stage 3a: dev, local development (NOT for production)
# docker build --target dev -t invoice-sync-api:dev .
############################################################
FROM build-base AS dev
ENV NODE_ENV=development \
TZ=UTC \
LOG_LEVEL=debug
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
COPY --chown=node:node . .
RUN mkdir -p /app/tmp && chown node:node /app/tmp
USER node
# 9229 = Node inspector. Exposed in dev ONLY.
EXPOSE 3000 9229
CMD ["npx", "nodemon", "--inspect=0.0.0.0:9229", "src/server.js"]
############################################################
# Stage 3b: test, CI gate (lint + unit tests)
# docker build --target test .
############################################################
FROM dev AS test
ENV NODE_ENV=test
RUN npm run lint && npm test
############################################################
# Stage 4: runtime, the production image
############################################################
FROM node:${NODE_VERSION}-${DEBIAN_RELEASE}-slim AS runtime
ARG SHARP_LIBVIPS
ARG VERSION=0.0.0-dev
ARG GIT_SHA=unknown
ARG BUILD_DATE=unknown
# OCI labels: GHCR uses 'source' to link the package to the repo.
LABEL org.opencontainers.image.title="invoice-sync-api" \
org.opencontainers.image.description="Syncs invoices between billing DB and accounting API" \
org.opencontainers.image.source="https://github.com/vongu3/invoice-sync-api" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.created="${BUILD_DATE}"
# - tini: a tiny init (~30 KB) that runs as PID 1. It forwards SIGTERM so the
# app can shut down gracefully on rolling deploys, and it reaps zombie processes.
# - libvips42: installed only in 'system' mode.
# - Removing npm/npx/corepack/yarn: they aren't needed at runtime, save ~20 MB,
# and are a common target for CVE scanners.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
tini \
$( [ "$SHARP_LIBVIPS" = "system" ] && echo libvips42 ) \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm \
/usr/local/lib/node_modules/corepack \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/opt/yarn-* /usr/local/bin/yarn /usr/local/bin/yarnpkg
# Non-secret runtime defaults only. DATABASE_URL, REDIS_URL, JWT_SECRET, and
# STRIPE_API_KEY are deliberately NOT set here. They are injected at runtime by
# the orchestrator so they never appear in image layers or `docker history`.
# TZ=UTC needs no tzdata package: glibc and Node's ICU resolve UTC natively.
ENV NODE_ENV=production \
TZ=UTC \
LOG_LEVEL=info
WORKDIR /app
# Dependencies first (they change less often than source), then the app.
# Files stay root-owned, so the runtime user can read but not modify code or
# node_modules. This limits the damage from an RCE.
COPY --from=prod-deps /app/node_modules ./node_modules
COPY package.json ./
# Copy source explicitly (not `COPY . .`) so tests, configs, and docs can never
# leak into the production image, even if .dockerignore drifts.
COPY src/ ./src/
# The only writable path. Mount a tmpfs over it in production (see run command).
RUN mkdir -p /app/tmp && chown node:node /app/tmp && chmod 0700 /app/tmp
# Numeric UID:GID lets Kubernetes' runAsNonRoot verify non-root status without
# looking up /etc/passwd. UID 1000 is the built-in 'node' user.
USER 1000:1000
EXPOSE 3000
# There is no curl or wget in slim, so use Node 20's built-in fetch. The check
# requires both HTTP 200 and the {"status":"ok"} body.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>r.json().then(b=>process.exit(r.ok&&b.status==='ok'?0:1))).catch(()=>process.exit(1))"]
ENTRYPOINT ["/usr/bin/tini", "--"]
# Run node directly, NOT `npm start`. npm doesn't forward signals and isn't
# installed in this stage anyway.
CMD ["node", "src/server.js"]
2. .dockerignore
Rationale: Keeping the build context small speeds up every build and prevents local node_modules (possibly built for another OS), .env files, and git history from ever reaching the daemon. Tests are deliberately not excluded, because the test stage needs them. The runtime stage copies only src/, so tests still never reach production.
# Dependencies & build output (always rebuilt inside the image)
node_modules
npm-debug.log*
.npm
coverage
.nyc_output
.eslintcache
dist
tmp
# Secrets & local config: must never enter the build context
.env
.env.*
!.env.example
*.pem
*.key
*.crt
secrets/
# VCS & CI metadata
.git
.gitignore
.github
.gitlab-ci.yml
# Docker files themselves (changes to them shouldn't bust the COPY cache)
Dockerfile*
.dockerignore
docker-compose*.yml
compose*.yml
# Editor / OS noise
.vscode
.idea
*.swp
.DS_Store
# Docs
*.md
docs/
3. Build, tag, push, and run commands
# ---- Variables (run from the repo root) ----
IMAGE=ghcr.io/vongu3/invoice-sync-api
VERSION=$(node -p "require('./package.json').version") # e.g. 1.4.2
MINOR=${VERSION%.*} # 1.4
MAJOR=${VERSION%%.*} # 1
GIT_SHA=$(git rev-parse HEAD)
SHORT_SHA=$(git rev-parse --short=7 HEAD)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# ---- Log in to GHCR (PAT or GITHUB_TOKEN needs write:packages) ----
echo "$CR_PAT" | docker login ghcr.io -u vongu3 --password-stdin
# ---- Optional: run the CI test gate ----
DOCKER_BUILDKIT=1 docker build --target test .
# ---- Build the production image ----
# Add --build-arg SHARP_LIBVIPS=system for the system-libvips variant.
DOCKER_BUILDKIT=1 docker build \
--platform linux/amd64 \
--target runtime \
--build-arg VERSION="$VERSION" \
--build-arg GIT_SHA="$GIT_SHA" \
--build-arg BUILD_DATE="$BUILD_DATE" \
-t "$IMAGE:$VERSION" \
.
# ---- Tag ----
docker tag "$IMAGE:$VERSION" "$IMAGE:sha-$SHORT_SHA" # every build (traceability)
# Only move rolling tags for stable releases (not 1.5.0-rc.1, etc.)
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
docker tag "$IMAGE:$VERSION" "$IMAGE:$MINOR" # 1.4
docker tag "$IMAGE:$VERSION" "$IMAGE:$MAJOR" # 1
fi
if [[ "$BRANCH" == "main" ]]; then
docker tag "$IMAGE:$VERSION" "$IMAGE:latest" # main only
fi
# ---- Push all local tags for this repo ----
docker push --all-tags "$IMAGE"
Run it in production style. Secrets are passed as -e VAR without a value, so Docker reads them from your shell environment. They don't appear in shell history or ps output.
docker run -d --name invoice-sync-api \
-p 3000:3000 \
--read-only \
--tmpfs /app/tmp:rw,noexec,nosuid,size=256m,uid=1000,gid=1000,mode=0700 \
--cap-drop ALL \
--security-opt no-new-privileges \
--memory 512m --cpus 1 \
-e DATABASE_URL \
-e REDIS_URL \
-e JWT_SECRET \
-e STRIPE_API_KEY \
"$IMAGE:$VERSION"
# Verify
docker inspect --format '{{.State.Health.Status}}' invoice-sync-api # -> healthy
curl -s localhost:3000/healthz # -> {"status":"ok"}
Local development (the inspector is available on 9229 in this target only):
docker build --target dev -t invoice-sync-api:dev .
docker run --rm -p 3000:3000 -p 127.0.0.1:9229:9229 -v "$PWD/src:/app/src" \
--env-file .env invoice-sync-api:dev
In Kubernetes, the equivalent settings are:
securityContext: {runAsNonRoot: true, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, capabilities: {drop: [ALL]}}- an
emptyDir: {medium: Memory}volume mounted at/app/tmp - secrets through
secretKeyRef - liveness and readiness probes on
GET /healthz
4. Expected image size
| Stage / variant | Approx. size (uncompressed) |
|---|---|
node:20.11.1-bookworm builder (discarded) |
~1.1 GB+ |
runtime, SHARP_LIBVIPS=bundled |
~230–270 MB (~75–90 MB compressed in the registry) |
runtime, SHARP_LIBVIPS=system |
~400–450 MB |
How the size was reduced:
- Slim runtime base. It saves roughly 850 MB compared with the full bookworm image.
- No compilers, headers, or python3 in the final image. They exist only in discarded stages.
- Production dependencies only.
--omit=devdrops jest, eslint, nodemon, and supertest, which usually make up 50–70% ofnode_modules. - npm, corepack, and yarn removed. About 20 MB saved.
- No leftover caches. BuildKit cache mounts keep the npm and apt caches out of every layer, and the runtime apt lists are deleted in the same layer they were created.
- Minimal app files. The explicit
COPY src/plus.dockerignorekeep tests, docs, and git history out.
These figures are estimates from typical base-image and sharp sizes. I haven't built the image against your repo. Run docker images and docker history $IMAGE:$VERSION to get real numbers, and dive to inspect individual layers.
Assumptions to confirm
- Entry point is
src/server.jsand all runtime code lives undersrc/. If you have other runtime directories (such asconfig/,migrations/, ortemplates/), add aCOPYline for each. - Plain JavaScript with no build step. If you use TypeScript or a bundler, I'd add a
buildstage that runsnpm run buildand copiesdist/instead ofsrc/. npm run lintandnpm testexist (used by thetesttarget).- amd64 only, matching the linux-x64 target in your spec. For arm64 (Graviton or Apple Silicon hosts), use
docker buildx build --platform linux/amd64,linux/arm64. sharp ships arm64 binaries too. - GitHub repo URL in the
org.opencontainers.image.sourcelabel is assumed to begithub.com/vongu3/invoice-sync-api. GHCR uses it to link the package to the repo. - Pin by digest in the long run. Tag pins like
20.11.1-bookworm-slimcan still be re-pushed with Debian security patches. For byte-for-byte reproducibility, add@sha256:…to bothFROMlines and let Renovate or Dependabot bump them. - Node 20.11.1 is out of support. Node 20 LTS reached end-of-life in April 2026, so this pinned patch no longer receives security fixes. Plan a move to Node 22 or 24 LTS. The only change needed in this Dockerfile is the
NODE_VERSIONarg.
Related prompts
Cloud Infrastructure Architect
Design a scalable, secure, cost-optimized cloud architecture with IaC, diagrams, and a phased rollout plan.
Cybersecurity Audit Specialist
Run a structured cybersecurity audit of an organization, prioritizing risks and producing an actionable remediation roadmap.
DevOps Automation Specialist
Acts as a DevOps engineer to design, optimize, and troubleshoot CI/CD pipelines, infrastructure as code, and cloud automation.
Ansible Automation Playbook Creator
Generates production-ready, idempotent Ansible playbooks and roles for any infrastructure automation or configuration task.