What Is Docker in CI/CD Pipelines?
Docker in CI/CD pipelines refers to the practice of using containerization throughout your continuous integration and continuous delivery workflow. Instead of relying on bare-metal runners or virtual machines with pre-installed dependencies, every stepāfrom building and testing to packaging and deployingāruns inside isolated, reproducible Docker containers.
At its core, the integration works like this: your CI/CD platform (GitHub Actions, GitLab CI, Jenkins, CircleCI, etc.) spins up a Docker container based on a specified image, executes your build or test commands inside that container, and then often produces a final Docker image that gets pushed to a registry and deployed to production.
The Anatomy of a Docker-Aware Pipeline
A typical Docker-enabled pipeline consists of these stages:
- Build stage: Compile source code, install dependencies, and run unit tests inside a container that mirrors production
- Image build stage: Use
docker buildto create a production-ready image, often leveraging multi-stage builds - Security scan stage: Scan the image for vulnerabilities using tools like Trivy, Clair, or Snyk
- Push stage: Tag and push the image to a container registry (Docker Hub, ECR, GCR, Harbor, etc.)
- Deploy stage: Roll out the new image to Kubernetes, ECS, Docker Swarm, or a simple VM via
docker pull && docker run
Why Docker Matters in CI/CD
š Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The marriage of Docker and CI/CD solves several persistent pain points that have plagued software delivery for decades.
Environment Parity
The infamous "it works on my machine" problem disappears. When your CI pipeline runs tests inside the exact same container image that will ship to production, you catch environment-specific bugs early. The OS, system libraries, runtime versions, and configuration are all identical across CI, staging, and production.
Reproducible Builds
Without containers, a CI runner accumulates state over timeāinstalled packages, cached dependencies, leftover artifacts. Two builds of the same commit can produce different results. Docker images are immutable snapshots. Given the same Dockerfile and context, you get the same output, making builds truly reproducible and auditable.
Speed and Parallelism
Containers start in seconds, not minutes. This allows CI/CD platforms to spin up isolated environments for every job in parallel. Matrix buildsātesting against multiple language versions, operating systems, or dependency setsābecome trivial by simply swapping the base image tag.
Simplified Dependency Management
All build-time and run-time dependencies are declared in a Dockerfile and version-controlled alongside the source code. New contributors can reproduce the entire build pipeline locally with a single docker build command. There's no sprawling wiki page of "how to set up your dev environment."
Immutable Deployment Artifacts
A Docker image is a single, versioned, self-contained artifact that includes everything needed to run the application. Rollbacks become as simple as changing a tag reference. There's no risk that a rollback target server has drifted or lost a critical package update.
How to Use Docker in CI/CD Pipelines
Let's walk through concrete implementations on three popular CI/CD platforms. Each example demonstrates a complete workflow: checkout, build, test, image build, scan, push, and deploy.
Example 1: GitHub Actions
Create a file at .github/workflows/docker-pipeline.yml:
name: Docker CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}/my-app
IMAGE_TAG: ${{ github.sha }}
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Run tests in container
run: |
docker run --rm \
--network host \
-e DATABASE_URL=postgres://postgres:postgres@localhost:5432/test \
-v ${{ github.workspace }}:/app \
-w /app \
node:20-alpine \
sh -c "npm ci && npm test"
build-and-push:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILDKIT_INLINE_CACHE=1
security-scan:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1
deploy:
needs: security-scan
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to remote server via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker pull ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
docker stop my-app || true
docker rm my-app || true
docker run -d --name my-app \
--restart unless-stopped \
-p 80:3000 \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
Key takeaways from this GitHub Actions pipeline:
- Service containers: The
servicesblock spins up a Postgres container alongside the test job, providing a real database for integration tests without manual setup - Buildx with GHA caching: The
docker/build-push-actionuses GitHub Actions cache backend (type=gha) to persist layer cache between runs, dramatically speeding up builds - Trivy scanning: The security scan job fails the pipeline if
CRITICALorHIGHvulnerabilities are found, preventing insecure images from reaching production - Environment protection: The deploy job uses
environment: productionwhich can enforce approval gates and secret isolation
Example 2: GitLab CI
Create a file at .gitlab-ci.yml:
variables:
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
DOCKER_LATEST: $CI_REGISTRY_IMAGE:latest
DOCKER_TLS_CERT_DIR: ""
stages:
- test
- build
- scan
- deploy
# Use Docker-in-Docker for image building
services:
- docker:27-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
test:
stage: test
image: node:20-alpine
script:
- npm ci
- npm run test:ci
- npm run lint
artifacts:
reports:
junit: junit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
build:
stage: build
image: docker:27
script:
- docker build
--cache-from $DOCKER_LATEST
--build-arg BUILDKIT_INLINE_CACHE=1
--tag $DOCKER_IMAGE
--tag $DOCKER_LATEST
.
- docker push $DOCKER_IMAGE
- docker push $DOCKER_LATEST
only:
- main
- tags
container-scanning:
stage: scan
image: docker:27
script:
- docker run --rm
-v /var/run/docker.sock:/var/run/docker.sock
aquasec/trivy
image --severity HIGH,CRITICAL
--exit-code 1
$DOCKER_IMAGE
only:
- main
deploy-production:
stage: deploy
image: alpine:3
before_script:
- apk add --no-cache openssh-client
script:
- |
ssh -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_HOST "
docker pull $DOCKER_IMAGE &&
docker stop my-app || true &&
docker rm my-app || true &&
docker run -d --name my-app
--restart unless-stopped
-p 80:3000
-e DATABASE_URL='$PROD_DATABASE_URL'
$DOCKER_IMAGE
"
environment: production
only:
- main
when: manual
Critical details for GitLab CI:
- Docker-in-Docker (dind): The
docker:27-dindservice provides a Docker daemon inside the CI job, allowing you to rundocker buildanddocker pushwithout mounting the host socket - Inline cache:
BUILDKIT_INLINE_CACHE=1embeds cache metadata directly in the image, so--cache-fromworks even without a separate cache backend - Manual deployment:
when: manualrequires a human to click "play" before deploying to production, adding an approval gate - Artifact reports: Test results are exported as JUnit and Cobertura XML, which GitLab visualizes in merge request widgets
Example 3: Jenkins (Declarative Pipeline)
Create a Jenkinsfile at the root of your repository:
pipeline {
agent none
environment {
DOCKER_REGISTRY = 'my-registry.example.com'
IMAGE_NAME = "${DOCKER_REGISTRY}/my-app"
IMAGE_TAG = "${env.BUILD_TAG ?: 'latest'}"
}
stages {
stage('Test') {
agent {
docker {
image 'node:20-alpine'
args '-v /tmp/.npm-cache:/root/.npm-cache:rw'
}
}
steps {
sh '''
npm ci --cache /root/.npm-cache
npm run test:ci
npm run lint
'''
}
post {
always {
junit 'junit.xml'
}
}
}
stage('Build Image') {
agent { label 'docker-builders' }
steps {
script {
def dockerfile = 'Dockerfile'
def buildArgs = '--build-arg BUILDKIT_INLINE_CACHE=1'
sh """
docker build \\
--cache-from ${IMAGE_NAME}:latest \\
${buildArgs} \\
--tag ${IMAGE_NAME}:${IMAGE_TAG} \\
--tag ${IMAGE_NAME}:latest \\
-f ${dockerfile} .
"""
}
}
}
stage('Security Scan') {
agent { label 'docker-builders' }
steps {
sh """
docker run --rm \\
-v /var/run/docker.sock:/var/run/docker.sock \\
aquasec/trivy image \\
--severity HIGH,CRITICAL \\
--exit-code 1 \\
${IMAGE_NAME}:${IMAGE_TAG}
"""
}
}
stage('Push to Registry') {
agent { label 'docker-builders' }
steps {
withCredentials([usernamePassword(
credentialsId: 'docker-registry-creds',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS'
)]) {
sh """
docker login -u ${REG_USER} -p ${REG_PASS} ${DOCKER_REGISTRY}
docker push ${IMAGE_NAME}:${IMAGE_TAG}
docker push ${IMAGE_NAME}:latest
docker logout ${DOCKER_REGISTRY}
"""
}
}
}
stage('Deploy') {
agent { label 'docker-builders' }
when {
branch 'main'
}
steps {
script {
input message: 'Deploy to production?', ok: 'Proceed'
}
withCredentials([
sshUserPrivateKey(
credentialsId: 'deploy-ssh-key',
keyFileVariable: 'SSH_KEY'
),
string(credentialsId: 'prod-db-url', variable: 'DB_URL')
]) {
sh """
ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no deploy@prod-server "
docker pull ${IMAGE_NAME}:${IMAGE_TAG} &&
docker stop my-app || true &&
docker rm my-app || true &&
docker run -d --name my-app \\
--restart unless-stopped \\
-p 80:3000 \\
-e DATABASE_URL='${DB_URL}' \\
${IMAGE_NAME}:${IMAGE_TAG}
"
"""
}
}
}
}
post {
failure {
notifyBuildStatus('failure')
}
success {
notifyBuildStatus('success')
}
}
}
Jenkins-specific considerations:
- Agent per stage: Different stages can run on different agent typesātests run inside ephemeral Docker containers while image builds use dedicated nodes with persistent Docker storage
- Credentials binding:
withCredentialssecurely injects registry passwords and SSH keys without exposing them in logs or environment dumps - Input step: The
inputdirective pauses the pipeline for manual approval before deployment - Post actions: The
postblock runs regardless of pipeline outcome, enabling notifications and cleanup
Best Practices
1. Use Multi-Stage Builds Aggressively
Multi-stage builds separate build-time dependencies from run-time artifacts. Your final image contains only what the application actually needs to run, not compilers, dev headers, or build tools.
# Stage 1: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production runtime
FROM node:20-alpine
RUN apk add --no-cache dumb-init
USER node
WORKDIR /app
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/package.json ./
ENV NODE_ENV=production
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main.js"]
Benefits: the final image might be 150MB instead of 900MB, and the attack surface is drastically reduced because there's no compiler, no source code, and no build tools.
2. Optimize Layer Caching with Dockerfile Order
Docker caches layers based on the instruction and the files copied. Structure your Dockerfile so that infrequently changed layers come first.
# Wrong: Copying all source before installing deps
COPY . .
RUN npm ci # Cache busted on ANY file change
# Correct: Install deps first, then copy source
COPY package*.json ./
RUN npm ci # Cached unless package.json changes
COPY . . # Only this layer is rebuilt on source changes
3. Leverage BuildKit for Advanced Caching
Enable BuildKit (set DOCKER_BUILDKIT=1 or use docker buildx) to unlock powerful cache features:
# Mount a cache directory for package managers
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Cache Go module downloads
FROM golang:1.22-alpine AS go-builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app/server .
The --mount=type=cache directive creates a persistent cache volume that survives between builds, even when the layer itself is rebuilt. This is far more efficient than COPY-based caching.
4. Never Commit Secrets to Images
Secrets baked into images are forever. Any developer with access to the registry can extract them. Use build-time secrets and runtime environment variables instead.
# BuildKit secret mounting
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci --//registry.npmjs.org/:_authToken=$NPM_TOKEN
# In CI, pass the secret:
# docker build --secret id=npm_token,src=.npm_token.txt .
For runtime secrets, inject them at container start via environment variables, mounted files, or a secrets managerānever hardcode them in the Dockerfile.
5. Pin Image Versions Explicitly
Floating tags like node:latest or alpine:3 (without minor version) introduce non-determinism. A build that passes today might fail tomorrow when the base image updates.
# Bad: floating tags
FROM node:alpine
# Good: pinned to digest
FROM node:20.11.1-alpine@sha256:a1b2c3d4e5f6...
# Practical compromise: pin minor version
FROM node:20.11-alpine
6. Use .dockerignore to Minimize Context
The Docker build context (everything sent to the daemon) should be as small as possible. A bloated context slows builds, wastes bandwidth, and can accidentally include secrets.
# .dockerignore
.git
.gitignore
.github
.gitlab-ci.yml
Jenkinsfile
node_modules
npm-debug.log
*.md
.vscode
.idea
.env
.env.*
coverage
dist
*.log
Dockerfile
docker-compose.yml
**/*.test.js
**/*.spec.js
Note that node_modules and dist are ignored from context but will be rebuilt inside the container. The context should contain only source files that the Dockerfile actually references.
7. Tag Images with Multiple Identifiers
A single image should carry multiple tags to support different consumers and workflows:
docker buildx build \
--tag my-app:latest \
--tag my-app:1.4.2 \
--tag my-app:${GIT_SHA} \
--tag my-app:${GIT_REF_NAME} \
.
latest: For development and quick starts- Semantic version: For release tracking and changelog alignment
- Git SHA: For absolute traceability back to a commit
- Branch name: For per-branch deployments (staging, preview environments)
8. Run as Non-Root User
By default, Docker runs processes as root inside the container. This is a security riskāif an attacker escapes the container, they gain root on the host.
FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /home/appuser/app
COPY --chown=appuser:appgroup . .
CMD ["node", "server.js"]
Many official images now support a built-in user. For example, node images have a node user with uid 1000. Always verify with docker run --rm my-image id that the process isn't root.
9. Implement Health Checks
A Docker health check lets orchestrators (Kubernetes, Swarm, Docker Compose) know if your container is actually functional, not just running.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
Without health checks, a container with a hung process can be considered "running" indefinitely while serving zero traffic.
10. Scan Images Before and After Every Build
Vulnerability scanning should happen both during CI (blocking merges) and continuously on stored images (catching newly discovered CVEs in old images).
# In CI pipeline
docker run --rm aquasec/trivy image --severity CRITICAL --exit-code 1 my-image:latest
# Scheduled job (cron) to re-scan all production images
for image in $(docker images --format '{{.Repository}}:{{.Tag}}'); do
trivy image --severity HIGH,CRITICAL "$image" && echo "PASS: $image" || notify-slack "FAIL: $image"
done
Common Pitfalls
Pitfall 1: Docker-in-Docker Without Proper Privileged Mode
Running Docker inside a Docker container (dind) requires the --privileged flag or specific capabilities. Many teams hit permission errors when their CI platform's security policy blocks privileged containers.
Symptom: docker: Cannot connect to the Docker daemon or permission denied when trying to access /var/run/docker.sock.
Solution A (safer): Use docker buildx with a remote builder or cloud-native builders that don't require local Docker access.
Solution B (if dind is required): Ensure your CI runner allows privileged containers and the dind service is properly configured:
# GitLab CI dind configuration
services:
- name: docker:27-dind
alias: docker
command: ["--tls=false"]
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERT_DIR: ""
DOCKER_DRIVER: overlay2
Pitfall 2: Cache Misses Due to COPY Ordering
The most common performance pitfall is a Dockerfile where COPY . . appears before RUN npm install. Any change to any fileāeven a README typo fixāinvalidates the entire dependency installation cache.
Fix: Always copy dependency manifests first, install dependencies, then copy source code.
# Correct order
COPY package.json package-lock.json ./
RUN npm ci
COPY src/ ./src/
Pitfall 3: Leaking Secrets in Build Logs or Layers
Build arguments (--build-arg) appear in plain text in image metadata and sometimes in build logs. Environment variables set with ENV are permanently embedded in the image layer.
Don't do this:
ARG AWS_SECRET_ACCESS_KEY
ENV AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
RUN aws s3 cp s3://bucket/file . # Key is now in image forever
Do this instead:
RUN --mount=type=secret,id=aws_key \
AWS_SECRET_ACCESS_KEY=$(cat /run/secrets/aws_key) \
aws s3 cp s3://bucket/file . && \
unset AWS_SECRET_ACCESS_KEY
Pitfall 4: Overly Large Images Slowing Deployments
Images that include compilers, SDKs, documentation, and test fixtures can exceed 1GB. These take minutes to pull on each deployment node, slowing rollouts and rollbacks.
Diagnosis: Use docker image history my-image and dive my-image to identify bloated layers.
Remediation:
- Implement multi-stage builds (see Best Practice #1)
- Use
apt-get clean && rm -rf /var/lib/apt/lists/*after installing packages - Combine RUN commands to reduce layers:
RUN apt update && apt install -y curl && apt clean && rm -rf /var/lib/apt/lists/* - Prefer Alpine-based images where possible (often 10x smaller than Debian equivalents)
Pitfall 5: Building as Root and Running as Root
Images built without a USER directive run as root. If a vulnerability allows arbitrary code execution, the attacker owns the container and potentially the host.
Fix: Add USER 1000 or a named user to every Dockerfile, and ensure the application doesn't require root privileges. For ports below 1024, use socat, iptables redirection at the infrastructure level, or simply bind to a high port and map it externally.
Pitfall 6: Not Handling Signals Correctly
When a container receives SIGTERM (during docker stop or pod eviction), the process gets 10 seconds to exit gracefully before SIGKILL. If your application ignores signals because it's wrapped in a shell script or npm start, graceful shutdown fails.
Fix: Use an init system like dumb-init or tini, or ensure your application is PID 1 and handles signals properly.
FROM node:20-alpine
RUN apk add --no-cache dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]
Pitfall 7: Non-Deterministic Builds
Floating base image tags, unpinned package versions, and timestamps in metadata produce different images from the same Dockerfile. This breaks audit trails and makes debugging production issues harder.
Fix:
- Pin base images to digests or at least minor versions
- Use
npm ciinstead ofnpm install(respects lockfile exactly) - Set
SOURCE_DATE_EPOCHfor reproducible timestamps - Consider using
docker build --build-arg BUILDKIT_INLINE_CACHE=1to embed cache metadata
Pitfall 8: Docker Socket Mounting vs. Docker-in-Docker
Mounting the host's Docker socket (-v /var/run/docker.sock:/var/run/docker.sock) into a CI container gives that container unrestricted access to the host's Docker daemon. This is convenient but dangerousāthe CI job can start, stop, or inspect any container on the host, including other pipelines' jobs.
Recommendation: Prefer Docker-in-Docker (dind) with an isolated daemon, or use a remote builder (like Buildx with a remote driver). If you must mount the socket, ensure the CI runner is single-tenant and ephemeral.
Pitfall 9: Ignoring Image Drift in Long-Running Environments
A deployed image might be secure today, but vulnerabilities are discovered continuously. Images that sit in a registry for months accumulate known CVEs without any visible change.
Mitigation: Schedule regular re-scanning of all production images and implement a policy for automated rebuilds when critical vulnerabilities are found, even if the application code hasn't changed.
Pitfall 10: Hardcoding Environment-Specific Configuration
Baking environment names, URLs, or credentials into images makes them non-portable. An image built for staging can't be promoted to production without rebuilding.
Principle: Build one immutable image, and configure it externally at runtime through environment variables, config files mounted at /etc/config, or a configuration service.
# Good: configuration passed at runtime
docker run -e LOG_LEVEL=debug -e DB_HOST=prod-db.internal my-app:1.0.0
# Bad: environment baked into image
ENV ENVIRONMENT=production
ENV DB_HOST=prod-db.internal
Putting It All Together: A Production-Grade Dockerfile Template
Here's a complete Dockerfile that incorporates multiple best practices discussed above:
# =============================================================================
# Stage 1: Build Stage
# =============================================================================
FROM node:20.11-alpine@sha256:abc123... AS builder
# Create non-root user early
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy dependency manifests first for cache optimization
COPY package.json package-lock.json ./
# Use BuildKit cache mount for npm
RUN --mount=type=cache,target=/root/.npm,uid=1000 \
npm ci --prefer-offline --no-audit --progress=false
# Copy source code
COPY . .
# Build the application
RUN npm run build && \
npm prune --production
# =============================================================================
# Stage 2: Production Image
# =============================================================================
FROM node:20.11-alpine@sha256:abc123...
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init=1.2.5-r2 curl
# Create application user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /home/appuser/app
# Copy only production artifacts from builder
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./
# Set production environment
ENV NODE_ENV=production
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]
This Dockerfile demonstrates:
- Pinned base image with digest for reproducibility
- Multi-stage build separating build and runtime environments
- Cache-optimized layer ordering with BuildKit cache mounts
- Non-root user for security
- Proper init system for signal handling
- Health check for orchestrator integration
- Minimal production footprint with
npm prune --production