Don't run as root
The default is root, and root inside the container maps to root on the host under most configurations. Any escape starts from the strongest possible position.
Add USER before the final CMD. If your app needs to bind a low port, front it with a proxy rather than granting the capability.
Minimal base images
node:20 is around a gigabyte of Debian, most of which is a shell, a package manager and utilities your application never uses. But an attacker with code execution does.
node:20-slim, -alpine, or a distroless image cut that considerably. Distroless has no shell at all, which removes most post-exploitation tooling.
Pin by digest rather than tag. node:20-alpine changes underneath you; node:20-alpine@sha256:... doesn't.
Secrets don't belong in images
Layers are permanent. Deleting a file in a later layer doesn't remove it from the earlier one, and docker history will show it.
Inject at runtime through environment variables or a secret manager. For build-time credentials, use BuildKit secret mounts, which don't persist into the image.
Multi-stage builds
Build tools, source and dev dependencies stay in the first stage. The shipped image contains only what runs.
Runtime restrictions
Read-only filesystem with a tmpfs mount for anything that needs writing. Drop all capabilities and add back only what's needed, most applications need none.
Never --privileged, and never mount /var/run/docker.sock into a container you don't fully trust. Access to the Docker socket is equivalent to root on the host.
Scanning
docker scout, Trivy or Grype will list known CVEs in your image layers. Run it in CI and fail on critical findings in the base image.
Most results come from the base image rather than your code, which is the strongest argument for the minimal image: fewer packages, fewer CVEs, less triage.