How to Write a Production-Ready Dockerfile
A production-ready Dockerfile starts with a slim base image, never runs as root, and exposes exactly one port. These three habits - slim base, dedicated user, explicit port - are the difference between a container that ships and one that gets flagged in a security review.
Start with a slim base image
python:3.12-slim gives you a working Python 3.12 runtime without the
full Debian toolchain. Fewer packages means a smaller attack surface and
a shorter CVE list for your security scanner to complain about.
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
Set WORKDIR before any COPY or RUN steps so all subsequent
commands resolve relative to that directory and the layer cache stays
predictable.
Never run as root
Containers run as root by default. A container escape hands the attacker
root on the host. Create a dedicated user and switch to it before the
final CMD:
RUN adduser --disabled-password --gecos "" appuser
USER appuser
--disabled-password prevents interactive login; --gecos "" skips the
name/room prompts. The app code runs as appuser - if an attacker
escapes the process, they land as an unprivileged user, not root.
Expose the port and start the app
EXPOSE 8080
CMD ["python", "app.py"]
EXPOSE is documentation - it tells docker run (and orchestrators like
Kubernetes) which port the container listens on. Use CMD as a list
(exec form), not a string (shell form), so signals like SIGTERM
reach the Python process directly instead of being swallowed by a shell.
The complete Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
RUN adduser --disabled-password --gecos "" appuser
USER appuser
EXPOSE 8080
CMD ["python", "app.py"]
Build, run, and verify:
docker build -t myapp .
docker run -p 8080:8080 myapp
curl http://localhost:8080/health
# {"status": "ok"}
What to add next in production
- Multi-stage builds - compile or install in one stage, copy only the artifact into the slim final stage so build tools never ship.
- HEALTHCHECK -
HEALTHCHECK CMD curl -f http://localhost:8080/healthlets Docker and Kubernetes detect a hung process without a sidecar. - Pinned digest - tag
python:3.12-slim@sha256:<digest>instead of a floating tag so a base-image update can't silently change your image. - COPY with
--chown-COPY --chown=appuser:appuser app.py .sets ownership at copy time and avoids a separatechownlayer.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Choosing a slim base image and setting WORKDIR correctly
- Creating a non-root user and switching to it with USER
- Using CMD in exec form and verifying the /health endpoint
FAQ
Why use python:3.12-slim instead of python:3.12?
The slim variant strips the full Debian toolchain, compilers, and headers, resulting in a much smaller image. Fewer installed packages means a smaller attack surface and fewer vulnerabilities for scanners to flag.
How do I run a Docker container as a non-root user?
Create a dedicated system user with RUN adduser --disabled-password --gecos '' appuser, then switch to it with USER appuser before the CMD instruction. The app process runs as that user for the lifetime of the container.
What is the difference between CMD and ENTRYPOINT in a Dockerfile?
ENTRYPOINT sets the fixed executable; CMD provides default arguments that can be overridden at docker run. For a simple app, CMD alone is fine. Use the exec form (a JSON array) so the process receives signals directly instead of through a shell.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →