Containers isolate apps from each other, but the defaults are built for convenience, not for a server on the public internet. A few settings close the most common gaps: containers running as root, ports published past the firewall, and a daemon socket that hands out root access. Work through this checklist one item at a time and test your app after each change.
Before you start
- A Linux VPS with Docker Engine and Compose, and SSH access with a sudo user.
- Your stacks defined in Compose files, so changes are easy to review and roll back.
- A basic host firewall in place. On Ubuntu and Debian, see UFW firewall on Ubuntu and Debian.
1. Keep the host and Docker updated
Container isolation relies on the Linux kernel and on Docker's runtime. Install security updates for both:
sudo apt update && sudo apt upgradeOn AlmaLinux or Rocky Linux, use sudo dnf upgrade. Reboot when a new kernel is installed.
2. Never expose the Docker socket to the network
The Docker API gives full root control of the server. Check that the daemon only listens on the local Unix socket:
sudo ss -ltnp | grep dockerdThere should be no output. If you see port 2375 or 2376, remove the -H tcp://... option from the service or the hosts entry from /etc/docker/daemon.json. For remote management, use SSH instead:
docker -H ssh://deploy@203.0.113.10 psTreat mounting /var/run/docker.sock into a container the same way. Tools like reverse proxies and dashboards ask for it; only give it to software you trust, and mount it read-only (:ro) where the tool allows. Read-only does not stop API calls, so it is a small help, not a fix. A socket proxy that only allows the API calls a tool needs is a stronger option.
3. Limit who is in the docker group
Every member of the docker group can become root. List them:
getent group dockerRemove anyone who does not need it with sudo gpasswd -d USER docker. For stronger isolation, consider Docker rootless mode.
4. Do not publish ports you do not need
Docker writes its own firewall rules, so a published port can be reachable even when UFW denies it. Publish only the reverse proxy on 80 and 443, and bind everything else to localhost:
services:
app:
ports:
- "127.0.0.1:3000:3000"Better still, leave ports: out and let the proxy reach the app over a Docker network. Then check what is listening publicly:
sudo ss -ltnpFix UFW and Docker bypassing the firewall explains the firewall interaction in detail.
5. Run containers as a non-root user
Check which user each container runs as:
docker ps -q | xargs docker inspect --format '{{.Name}} user={{.Config.User}}'An empty user= means root. In your own Dockerfiles, add a user and switch to it:
RUN useradd --system --uid 10001 app
USER appFor third-party images, check their docs for a non-root option, or set one in Compose with user: "10001:10001" and fix volume ownership to match.
6. Drop capabilities and block privilege escalation
Linux capabilities split root's powers into pieces. Most web apps need none of them. Drop all and add back only what fails:
services:
app:
image: myapp:1.0
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueno-new-privileges stops a process from gaining rights through setuid binaries. If an image such as Nginx needs to bind port 80 inside the container, add cap_add: [NET_BIND_SERVICE], or configure it to listen on a high port.
Never use privileged: true unless a tool's documentation explains exactly why it needs it.
7. Use a read-only root filesystem
A read-only container cannot be modified by an attacker who gets in. Give writable space only where the app needs it:
services:
app:
image: myapp:1.0
read_only: true
tmpfs:
- /tmp
volumes:
- app-data:/app/dataStart the container and watch the logs. Any Read-only file system error tells you another path that needs a volume or tmpfs.
8. Set resource limits
A container that leaks memory or is abused for crypto mining should not take the whole server down:
services:
app:
mem_limit: 512m
cpus: 1
pids_limit: 2009. Use trusted, pinned images and scan them
- Prefer official images and images from the software's own publisher.
- Pin version tags rather than
latest, so updates happen when you choose. - Scan images for known vulnerabilities before you deploy. Docker Scout (
docker scout cves myapp:1.0) and the open-source Trivy are common choices. See Scan Docker images for vulnerabilities. - Rebuild your own images regularly so they pick up base image fixes.
10. Keep secrets out of images
Do not bake passwords or keys into images or commit .env files to Git. Pass them at run time and lock down the files that hold them with chmod 600.
Check it works
Inspect a hardened container:
docker inspect app --format 'user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}} capdrop={{.HostConfig.CapDrop}} sec={{.HostConfig.SecurityOpt}}'You should see a non-empty user, ro=true, capdrop=[ALL] and no-new-privileges:true. From another machine, scan your server's ports with nmap 203.0.113.10 and confirm only SSH, 80 and 443 are open.
Troubleshooting
- App fails with
Operation not permittedaftercap_drop: ALL: it needs one capability back. The error or the image docs usually say which, oftenCHOWN,SETUID,SETGIDorNET_BIND_SERVICE. Read-only file systemerrors: add atmpfsor volume for that path.- Permission denied on volumes after switching to a non-root user:
chownthe volume data to the new UID. - Port still reachable from outside: it is published without
127.0.0.1. Fix theports:line and recreate the container.
Next steps
- Strengthen the daemon's isolation: Run Docker in rootless mode on Ubuntu.
- Scan before you ship: Scan Docker images for vulnerabilities.
- Run hardened containers on a Linux VPS.
