You spin up a Docker host on your VPS, deploy a few containers, and everything works. A month later you discover someone used an exposed API to mine crypto or exfiltrate database backups. In support tickets I handled, the usual culprit was a small configuration oversight—running containers with more privilege than they need, mounting the Docker socket inside a container, or forgetting to set resource limits.
This guide walks through nine hardening mistakes that break container isolation and gives you the correct approach for each. Check your setup against this list before you push to production.
Mistake 1: Running containers as root
Most images default to UID 0 inside the container. If an attacker escapes the container namespace, they land on the host as root. I've seen this turn a WordPress plugin RCE into full VPS compromise in under five minutes.
Switch to a non-root user in your Dockerfile:
FROM node:18-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --chown=appuser:appgroup . /app
WORKDIR /app
CMD ["node", "server.js"]
For images you don't control, override the user at runtime:
docker run --user 1000:1000 nginx:alpine
You can also enforce this policy daemon-wide by adding "userns-remap": "default" to /etc/docker/daemon.json and restarting Docker. User namespaces remap container root to an unprivileged UID on the host, so even if the container runs as UID 0 inside, the host kernel sees a high-numbered UID with no special rights.
Mistake 2: Using --privileged or excessive capabilities
The --privileged flag disables almost every security layer Docker provides. It gives the container access to all host devices and lets it load kernel modules.
Drop it. If you need specific hardware access—say, a GPU or a USB device—pass through only that device:
docker run --device=/dev/video0 myapp
For capabilities, start by dropping all and adding back only what the app needs:
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx:alpine
Most web apps need NET_BIND_SERVICE to bind ports below 1024. Database containers occasionally need SYS_NICE for priority tuning. Anything else is a red flag. Check the container's actual requirements before adding capabilities—guessing wrong still leaves attack surface.
Mistake 3: Mounting /var/run/docker.sock
This one shows up in CI/CD pipelines and monitoring stacks. Mounting the Docker socket lets a container control the Docker daemon, which runs as root on the host. An attacker who compromises that container can spawn a new privileged container and break out.
Instead, use Docker-in-Docker with a separate daemon, or better yet, use a rootless Docker setup where the daemon itself runs as a non-root user. For CI, many modern tools—like Kaniko or Buildah—build images without needing the Docker daemon at all.
If you absolutely must mount the socket, at least run that container with read-only access and put it behind strict network policies:
docker run -v /var/run/docker.sock:/var/run/docker.sock:ro monitoring-tool
But read-only still lets an attacker inspect all containers, including their environment variables and secrets. The risk remains high.
Mistake 4: Skipping resource limits
Containers without memory or CPU limits can consume the entire host. A single runaway process—whether malicious or just buggy—will starve every other workload and often crash the VPS.
Set limits in your run command:
docker run -m 512m --cpus="1.0" myapp
Or in docker-compose.yml:
services:
web:
image: myapp
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
Reservations guarantee a baseline; limits cap the maximum. I usually set the limit to roughly twice the reservation to allow for bursts without letting one container hog everything.
For production workloads, also set --pids-limit to prevent fork bombs:
docker run --pids-limit=100 myapp
Mistake 5: Exposing unnecessary ports
Every -p 3306:3306 or -p 6379:6379 you publish is a new attack vector. MySQL and Redis are not designed to face the internet, yet I regularly see them bound to 0.0.0.0 on shared VPS instances.
Bind only to localhost if the client is on the same host:
docker run -p 127.0.0.1:3306:3306 mysql
Better: use Docker networks. Containers on the same user-defined bridge can talk to each other by service name without exposing ports to the host:
docker network create app-net
docker run --name db --network app-net mysql
docker run --name web --network app-net -p 80:80 myapp
The web container can reach the database at db:3306, but nothing outside the network can. If you do need external access, put a reverse proxy in front and lock down the backend with firewall rules.
Mistake 6: Using :latest or unverified images
Pulling :latest means you have no idea what code is running. An upstream compromise or a breaking change can take down your app with zero warning. Worse, you can't reproduce your environment after a failure.
Pin specific image digests:
docker pull nginx@sha256:abcd1234...
Or at minimum, pin a version tag:
FROM node:18.17.1-alpine
For third-party images, scan them before deployment. Tools like Trivy and Grype catch known CVEs in base layers and application dependencies:
trivy image node:18-alpine
If the scan shows critical vulnerabilities, either update to a patched tag or choose a different base image. Official images are generally safer than random community builds, but you still need to verify.
Mistake 7: Ignoring AppArmor and seccomp profiles
Docker ships with a default seccomp profile that blocks around 44 dangerous syscalls. Some teams disable it to avoid compatibility issues, which is like removing your seatbelt because it wrinkles your shirt.
The default profile is already permissive. Keep it:
docker run --security-opt seccomp=/path/to/default.json myapp
If your app genuinely needs a blocked syscall, create a custom profile that allows only that call. Disabling seccomp entirely (--security-opt seccomp=unconfined) should be a last resort, not a first step.
Similarly, AppArmor or SELinux policies add another layer. On Ubuntu, Docker automatically applies the docker-default AppAmor profile. Verify it's active:
docker inspect --format='{{.AppArmorProfile}}' container_name
If you see unconfined, something overrode the default. Check your run flags and daemon config.
Mistake 8: Not using read-only filesystems
Many containers never need to write to their own filesystem. Log files go to stdout, data goes to mounted volumes, and the app code is static. Running these containers read-only blocks an entire class of attacks that rely on dropping malicious binaries or modifying config files.
Add --read-only and mount a tmpfs for any directories that do need writes:
docker run --read-only --tmpfs /tmp --tmpfs /var/run myapp
For persistent data, use a named volume:
docker run --read-only -v appdata:/data myapp
This way the attacker can't modify /usr/bin or /etc, even if they achieve code execution. They're limited to the specific writable paths you allowed.
Mistake 9: Forgetting to rotate secrets and credentials
Hardcoding database passwords in environment variables is convenient. It's also how credentials leak into logs, crash dumps, and process listings. Once an attacker has shell access, docker inspect or cat /proc/1/environ hands them everything.
Use Docker secrets on Swarm or external secret managers like Vault or AWS Secrets Manager. Mount secrets as files, not environment variables:
docker service create \
--secret db_password \
--env DB_PASSWORD_FILE=/run/secrets/db_password \
myapp
Your application reads the file at runtime. Rotate the secret every 90 days and update the service. For non-Swarm setups, you can mount secrets from an encrypted volume or fetch them from a secret manager at startup.
Never commit secrets to your image layers. Even if you delete the file in a later layer, it's still in the image history. Use .dockerignore and environment-specific config files.
What if a container still breaks out?
Even with hardening, assume a sufficiently motivated attacker can escape. Defense in depth means each layer slows them down. Monitor your host for unusual process activity, unexpected outbound connections, and changes to system binaries. Run an intrusion detection tool like OSSEC or Wazuh and set up alerts for privilege escalation attempts.
Keep the host kernel patched. A kernel exploit bypasses every container-level control. Enable automatic security updates or at minimum check for patches weekly.
Frequently Asked Questions
Do I need all nine fixes on every container?
No. A frontend web server might need different controls than a batch processing job. Start with the basics—non-root user, resource limits, no privileged mode—and add the rest based on what data the container handles and how exposed it is.
Can I harden Windows containers the same way?
Most of these principles apply, but the implementation differs. Windows containers don't use seccomp or AppArmor; they rely on Windows Defender Application Control and Hyper-V isolation. User namespaces and read-only filesystems work similarly.
How do I test if my hardening broke the app?
Apply changes in a staging environment first. Run your full test suite, then simulate failure scenarios—kill processes, fill disk, exhaust memory—to confirm limits behave as expected. Check logs for permission errors or missing capabilities.
Is Docker hardening enough, or do I need Kubernetes?
Kubernetes adds orchestration and policy enforcement at scale, but it's not inherently more secure. You still need to apply the same container-level controls—non-root users, dropped capabilities, resource limits. K8s gives you tools like Pod Security Standards and Network Policies, which help enforce those controls consistently across a fleet.
Where to start today
Pick the three mistakes your setup is most guilty of and fix those first. For most teams, that's running as root, skipping resource limits, and exposing internal services. You can fix all three in an afternoon with a few Dockerfile edits and updated run commands.
Once those are done, work through the rest of the list. Hardening is not a one-time checklist; it's an ongoing practice. New vulnerabilities appear, dependencies change, and your app's needs evolve. Review your container security every quarter and after any major deployment.
Your VPS is only as secure as the least-hardened container running on it. Fix the low-hanging fruit now, before someone else finds it.
