Security & HEALTHCHECK
Why container security matters
Section titled “Why container security matters”By default, processes inside a Docker container run as root (UID 0). If an attacker exploits a vulnerability in your application, they inherit root privileges inside the container — and depending on your host configuration, that can translate to host-level access. Two simple Dockerfile changes neutralise the most common risks.
Running as a non-root user
Section titled “Running as a non-root user”The USER instruction sets the user that all subsequent RUN, CMD, and ENTRYPOINT instructions execute as.
# syntax=docker/dockerfile:1FROM node:22-alpine
# Create a dedicated user and groupRUN addgroup -S app && adduser -S app -G app
WORKDIR /appCOPY --chown=app:app package.json package-lock.json ./RUN npm ci --omit=devCOPY --chown=app:app src/ ./src/
# Switch to the non-root user before CMDUSER app
EXPOSE 3000CMD ["node", "src/server.js"]Key details:
addgroup -Sandadduser -Screate a system (no-login, no home-dir by default) group and user — appropriate for service accounts.--chown=app:apponCOPYsets file ownership in the same layer, avoiding a separateRUN chowninstruction.USER appmust come after allRUNinstructions that need root (package installs, etc.).
Never bake secrets into images
Section titled “Never bake secrets into images”Every layer in a Docker image is stored on disk and can be inspected with docker history or extracted from the registry. Secrets written into a RUN instruction — even if deleted in a later layer — remain visible in the build history.
# WRONG — the token is permanently in the image historyRUN curl -H "Authorization: Bearer ${SECRET_TOKEN}" https://api.example.com/dataCorrect approaches:
-
Build-time secrets via BuildKit — never stored in the image:
RUN --mount=type=secret,id=api_token \curl -H "Authorization: Bearer $(cat /run/secrets/api_token)" https://api.example.com/dataPass with:
docker build --secret id=api_token,env=SECRET_TOKEN . -
Runtime environment variables — pass secrets at
docker runtime, not at build time:Terminal window docker run -e DATABASE_URL="postgres://user:pass@host/db" myapp -
Secrets managers — inject secrets via Vault, AWS Secrets Manager, or Kubernetes Secrets at pod startup.
HEALTHCHECK
Section titled “HEALTHCHECK”The HEALTHCHECK instruction tells Docker how to test whether your container is functioning. Docker runs the command periodically; if it fails consecutively, the container status switches to unhealthy and orchestrators (Swarm, Kubernetes liveness probes equivalent) can restart it automatically.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO- http://localhost:3000/health || exit 1Parameters:
--interval— how often to run the check (default: 30s)--timeout— how long to wait before the check is considered failed (default: 30s)--start-period— grace period after container start before failures count (default: 0s)--retries— how many consecutive failures mark the container unhealthy (default: 3)
Verify the health status:
docker ps --format "table {{.Names}}\t{{.Status}}"# NAME STATUS# myapp Up 2 minutes (healthy)Scanning with docker scout
Section titled “Scanning with docker scout”Docker Scout analyses your image layers against vulnerability databases and reports CVEs.
# Scan a local imagedocker scout cves myapp:prod
# Compare two tagsdocker scout compare myapp:prod myapp:naiveA typical report looks like:
0C 2H 12M 3L | myapp:prod 0C 8H 45M 12L | myapp:naiveC = Critical, H = High, M = Medium, L = Low. Switching to a slim or distroless base typically eliminates the majority of High and Critical CVEs.
Integrate scanning into CI to catch regressions before they reach production:
docker scout cves --exit-code --only-severity critical,high myapp:prod--exit-code causes the command to return a non-zero exit code if any matching CVEs are found — blocking the pipeline.
Hands-on: non-root + HEALTHCHECK Dockerfile
Section titled “Hands-on: non-root + HEALTHCHECK Dockerfile”# syntax=docker/dockerfile:1
FROM node:22-alpine
# Create a non-root user
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
# Inline a minimal HTTP server
RUN echo 'const http=require("http");' > server.js && echo 'http.createServer((req,res)=>{' >> server.js && echo ' if(req.url==="/health"){res.writeHead(200);res.end("ok\n");}' >> server.js && echo ' else{res.writeHead(200);res.end("Hello (running as non-root)!\n");}' >> server.js && echo '}).listen(3000,()=>console.log("Listening on 3000"));' >> server.js
# Set ownership and switch user
RUN chown -R app:app /app
USER app
EXPOSE 3000
# Health check — polls /health every 10s
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node","server.js"]
# --- Build and run ---
# docker build -t myapp:secure .
# docker run --rm -p 3000:3000 myapp:secure
# docker ps (check STATUS for "healthy")