Docker in Production: Security and Best Practices
Make images and containers production-ready: a non-root user, a real web server, health checks, resource limits, read-only filesystems, log limits and a deployment checklist.
On this page 10 sections
Introduction
A container that works on your laptop is not automatically ready for real users. Production adds new requirements: security, reliability, predictable resource use and logs that do not fill the disk. This final lesson upgrades Priya’s Quotes API to meet them.
- How to write a production Dockerfile with a non-root user, a real web server and a HEALTHCHECK.
- How to run containers with restart policies, resource limits, a read-only filesystem and log limits.
- The image habits that keep production safe and predictable.
- How a typical single-server deployment works, with a checklist to follow.
A Production Dockerfile
Flask’s built-in server is for development only. Priya adds gunicorn, a production-grade Python web server, to requirements.txt:
flask==3.1.3gunicorn==26.2.0FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Run as an unprivileged user instead of rootRUN useradd --create-home --uid 10001 appuserUSER appuser
EXPOSE 5000
HEALTHCHECK \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" || exit 1
# A production web server instead of Flask's development serverCMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "app:app"]| Line | Why |
|---|---|
| PYTHONDONTWRITEBYTECODE=1 | Do not write .pyc files at run time |
| PYTHONUNBUFFERED=1 | Send output straight to docker logs instead of buffering it |
| useradd … / USER appuser | Run the app as an unprivileged user (see below) |
| HEALTHCHECK | Docker checks every 10 seconds that the app answers; start-period gives it time to boot |
| gunicorn … --workers 2 app:app | A production server with two worker processes, serving the app object from app.py |
docker build -t quotes-api:2.0 .Do Not Run as Root
By default a container’s processes run as root. Compare the old and the new image:
docker run --rm quotes-api:1.0 whoami # rootdocker run --rm quotes-api:2.0 whoami # appuserdocker run --rm quotes-api:2.0 id # uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)Containers are isolated, but not perfectly. If an attacker breaks into an app running as root, any weakness in that isolation gives them root-level power. As an ordinary user they can do far less. It costs two lines in the Dockerfile — add them to every image you build.
Running It Safely
docker run -d --name api \ -p 5000:5000 \ --restart unless-stopped \ --memory 256m --cpus 0.5 \ --read-only --tmpfs /tmp \ --log-opt max-size=10m --log-opt max-file=3 \ quotes-api:2.0| Option | Effect |
|---|---|
| --restart unless-stopped | Restart after crashes and server reboots |
| --memory 256m | Hard memory limit; the process is killed (exit 137) if it goes over, instead of starving the server |
| --cpus 0.5 | At most half of one CPU core |
| --read-only | The container’s filesystem cannot be modified, so an attacker cannot plant files |
| --tmpfs /tmp | A small in-memory folder for the temporary files gunicorn needs |
| --log-opt max-size / max-file | Rotate logs: at most three files of 10 MB, so logs can never fill the disk |
Check the result: docker logs api now shows gunicorn starting two workers (and no development-server warning), docker ps shows (healthy) after the first check, docker stats --no-stream api shows the 256 MiB memory limit, and any attempt to write a file in /app fails with “Read-only file system”.
A container on Priya’s server keeps writing large logs and filled the disk last month. Which option was missing from its docker run command?
Image Best Practices
- Pin base images to a version (python:3.14-slim, not python:latest), and rebuild regularly to pick up security fixes.
- Use small bases (slim, alpine, distroless) and multi-stage builds, so there is less to attack.
- Use .dockerignore and never put secrets in images (lesson 9).
- One main process per container; run other services in their own containers.
- Scan images for known vulnerabilities, for example with docker scout or open-source tools such as Trivy.
- Tag every release with a version and deploy exact tags, never latest (lesson 13).
Deploying to a Server
A simple, common production setup for one server looks like this:
- A Linux server (cloud VM) with Docker Engine installed (lesson 2) and a firewall allowing only SSH, HTTP and HTTPS.
- Images built by CI/CD and pushed to a registry with version tags (lesson 13).
- A compose.yaml on the server describing the app, its database (with a volume) and a reverse proxy such as Caddy or Nginx for HTTPS.
- Deploying a new version: change the tag in compose.yaml, then docker compose pull and docker compose up -d.
- Regular database backups copied off the server, and monitoring of disk space and container health.
The “Multi-Service Deployment with a Caddy Reverse Proxy” project walks through exactly this setup. When you outgrow one server, Kubernetes runs the same images across many machines.
Production Checklist
| Area | Check |
|---|---|
| Image | Pinned slim base, multi-stage if compiled, .dockerignore, no secrets |
| User | USER set to a non-root user |
| Server process | A production server (gunicorn, uvicorn, node, nginx), not a dev server |
| Health | HEALTHCHECK in the Dockerfile |
| Reliability | Restart policy unless-stopped |
| Resources | Memory and CPU limits |
| Data | Named volumes for anything stateful, with backups |
| Logs | Log rotation limits |
| Network | Only the reverse proxy published; databases internal |
| Releases | Version tags, a registry and a rollback plan |
Put a safe production release in order.
- docker compose up -d and watch health status and logs
- Scan it and run the tests
- Push it to the registry
- Update the tag on the server and run docker compose pull
- Build the image with a new version tag
Frequently Asked Questions
CI/CD (for example GitHub Actions) to build and push images automatically, a cloud platform such as AWS to run them, and later Kubernetes for running containers across many servers.
No. USER changes who runs the app inside the container. Rootless mode runs the Docker daemon itself without root on the host. Both improve security and can be combined.
A common starting point is two to four per CPU core available to the container, then adjust based on measurements.
Key Takeaways
- Use a production server, a non-root USER and a HEALTHCHECK in your Dockerfile.
- Run with a restart policy, memory and CPU limits, a read-only filesystem and log rotation.
- Keep images small, pinned, scanned, versioned and free of secrets.
- A single server typically runs Compose with a reverse proxy, volumes and backups.
Summary
Congratulations — you have gone from your first docker run to production-ready containers: images, Dockerfiles, networking, volumes, configuration, Compose, multi-stage builds, debugging, registries and security. Put it all into practice with the projects.