Moving services from VMs into containers taught me that “it runs” is not the same as “it’s a good image.” These are the habits I now use on every Dockerfile.
1. Use multi-stage builds
Build in one stage, copy only the result into a small final stage. Compilers, build tools and caches never reach production.
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /install /usr/local
COPY . .
CMD ["python", "app.py"]
2. Don’t run as root
By default, a container’s process runs as root. If someone breaks into the app, they’re root inside the container, which makes escaping or doing damage much easier. Create a user and switch to it:
RUN useradd --create-home --uid 10001 app
USER app
3. Pin your base image
python:latest can change under you at any time. Pin a specific version, and for production pin the digest too, so the build is reproducible:
FROM python:3.12-slim@sha256:<digest>
4. Use a .dockerignore
Without one, COPY . . sends everything to the build: .git, local .env files, test data. That makes images bigger and can leak secrets.
.git
.env
__pycache__/
node_modules/
*.log
5. Never bake secrets into layers
Every RUN, COPY and ENV creates a layer, and layers are readable by anyone who pulls the image, even if you delete the file in a later step. Pass secrets at runtime (environment, mounted files, a secrets manager) or use BuildKit secret mounts during the build.
6. Scan the image in CI
Add a scanner like Trivy to the pipeline so known vulnerabilities in the base image or dependencies fail the build before they ship:
trivy image --exit-code 1 --severity HIGH,CRITICAL my-app:latest
None of these take long, and together they make a real difference in both size and attack surface.