Container Security
Containers are not sandboxes
Docker containers provide isolation, but they are not impenetrable. If an attacker exploits a vulnerability in your app, they are inside the container. Reducing what they can do inside limits the damage.
Run as non-root
By default, the process inside a container runs as root. If an attacker gains code execution, they are root inside the container — and can potentially escape to the host.
Add a non-root user to your Dockerfile:
# In the production stage
FROM node:22-alpine AS production
# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
# Change ownership of the app directory
RUN chown -R appuser:appgroup /app
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"] The USER appuser instruction means the Node.js process runs as appuser, not root. An attacker who gains code execution cannot install packages, modify system files, or access root-only resources.
[!NOTE] If your app writes to the filesystem (SQLite database, uploads), the non-root user needs write permission to those directories. Volumes mounted at runtime inherit the container’s user, so
chownis only needed for directories inside the image.
Read-only filesystem
Make the container’s filesystem read-only. The app can only write to explicitly mounted volumes:
docker run -d \
--read-only \
--tmpfs /tmp \
-v app-data:/data \
myapp --read-only makes the entire filesystem read-only. --tmpfs /tmp creates a writable tmpfs (in-memory filesystem) for temporary files. The app-data volume at /data is writable for the database.
In Docker Compose:
services:
app:
build: .
read_only: true
tmpfs:
- /tmp
volumes:
- app-data:/data An attacker cannot write scripts, modify binaries, or create files outside the mounted volume. This severely limits what they can do after gaining access.
Resource limits
Without limits, a single container can consume all the server’s CPU and memory, starving other containers and the host:
services:
app:
build: .
deploy:
resources:
limits:
cpus: "1.0" # Maximum 1 CPU core
memory: 512M # Maximum 512 MB RAM
reservations:
cpus: "0.25" # Guaranteed 0.25 CPU cores
memory: 128M # Guaranteed 128 MB RAM With docker run:
docker run -d \
--cpus="1.0" \
--memory="512m" \
myapp If the app exceeds the memory limit, Docker kills the container (OOM kill). This is better than the alternative: the entire server running out of memory.
Minimal image
The Alpine-based multi-stage build from earlier is already a security win: fewer packages mean fewer potential vulnerabilities. But you can go further:
# Check what is in the image
docker run --rm myapp apk list --installed Remove anything the app does not need. The fewer binaries in the container, the less an attacker has to work with.
Do not run privileged
Never use --privileged unless you know exactly what you are doing. It gives the container full access to the host, defeating the purpose of containerization.
# NEVER in production
docker run --privileged myapp
# This is fine
docker run myapp Exercises
Exercise 1: Add the non-root user to your Dockerfile. Build and run. Verify: docker exec myapp whoami should print appuser, not root.
Exercise 2: Run with --read-only. Try to create a file inside the container: docker exec myapp touch /test. It should fail. Verify you can still write to the volume.
Exercise 3: Set a memory limit of 256 MB. Check usage with docker stats. What happens if the app exceeds the limit?
Why should containers run as a non-root user?