Skip to content

Security & HEALTHCHECK

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.

The USER instruction sets the user that all subsequent RUN, CMD, and ENTRYPOINT instructions execute as.

# syntax=docker/dockerfile:1
FROM node:24-alpine
# Create a dedicated user and group
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=app:app src/ ./src/
# Switch to the non-root user before CMD
USER app
EXPOSE 3000
CMD ["node", "src/server.js"]

Key details:

  • addgroup -S and adduser -S create a system (no-login, no home-dir by default) group and user — appropriate for service accounts.
  • --chown=app:app on COPY sets file ownership in the same layer, avoiding a separate RUN chown instruction.
  • USER app must come after all RUN instructions that need root (package installs, etc.).

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 history
RUN curl -H "Authorization: Bearer ${SECRET_TOKEN}" https://api.example.com/data

Correct approaches:

  1. 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/data

    Pass with: docker build --secret id=api_token,env=SECRET_TOKEN .

  2. Runtime environment variables — pass secrets at docker run time, not at build time:

    Terminal window
    docker run -e DATABASE_URL="postgres://user:pass@host/db" myapp
  3. Secrets managers — inject secrets via Vault, AWS Secrets Manager, or Kubernetes Secrets at pod startup.

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 1

Parameters:

  • --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:

Terminal window
docker ps --format "table {{.Names}}\t{{.Status}}"
# NAME STATUS
# myapp Up 2 minutes (healthy)

Docker Scout analyses your image layers against vulnerability databases and reports CVEs.

Terminal window
# Scan a local image
docker scout cves myapp:prod
# Compare two tags
docker scout compare myapp:prod myapp:naive

A typical report looks like:

0C 2H 12M 3L | myapp:prod
0C 8H 45M 12L | myapp:naive

C = 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:

Terminal window
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:24-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")
OptionBenefitCost
Runtime base on alpineHas a shell, so you can debug by exec’ing into a running containerStill has a shell and package manager an attacker can abuse if they get in
Runtime base on distrolessNo shell, no package manager, much smaller attack surfaceInteractive debugging is nearly impossible — you have to rely on logs and HEALTHCHECK instead
  • Baking a secret like an API key or npm token into a RUN instruction and assuming a later RUN rm deletes it, when it is still visible in the layer history that docker history can see.
  • Skipping HEALTHCHECK, so the orchestrator cannot tell a hung container from a healthy one and never restarts it automatically.
  • Accidentally running the main process as root, letting a vulnerability’s blast radius extend from the application straight to container-level privileges.

💡 Real-world example

Kubernetes uses the same mechanism as HEALTHCHECK through liveness and readiness probes to automatically restart a hung pod. Teams that deploy distroless images alongside a correctly configured health check significantly cut downtime from containers that silently hang.

Why is running a container as root considered a security risk?
What happens to a secret written in a RUN instruction and then deleted in a later layer?
What does the --start-period option in HEALTHCHECK control?
Which docker scout flag causes the command to return a non-zero exit code when vulnerabilities are found — useful for blocking CI pipelines?