# Docker for Backend Engineers Who Aren't DevOps

Most backend engineers learn Docker by copying a Dockerfile from the internet, running it locally, and moving on. That works until it doesn't — images that are 2GB when they should be 200MB, builds that take four minutes when they could take forty seconds, containers that behave differently in CI than they do locally.

Three concepts explain most of these problems: how layers work, how the build cache works, and what multi-stage builds are for. None of them are complicated once you understand them. They're just not obvious from the outside.

* * *

## Layers: Why Order Matters

Every instruction in a Dockerfile creates a layer — a filesystem snapshot of what changed. Layers are cached, immutable, and stacked. When you build an image, Docker checks each layer against its cache. If the instruction and its inputs haven't changed, it uses the cached layer and skips re-running the instruction.

This is why instruction order matters enormously:

```dockerfile
# ❌ Wrong order — cache busted on every code change
FROM python:3.12-slim
WORKDIR /app
COPY . .                          # copies all code — changes every build
RUN pip install -r requirements.txt  # cache busted because COPY changed
```

```dockerfile
# ✅ Right order — dependencies cached separately from code
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .           # only changes when dependencies change
RUN pip install -r requirements.txt  # cached until requirements.txt changes
COPY . .                          # code changes here — only this layer rebuilds
```

In the wrong version, every code change invalidates the pip install layer — Docker re-downloads and reinstalls every dependency on every build. In the right version, pip install is cached until `requirements.txt` changes. Code changes only rebuild the final `COPY` layer.

Rule: put things that change rarely at the top, things that change frequently at the bottom.

* * *

## Multi-Stage Builds: Smaller Images, Faster Deploys

The image you build in development contains everything needed to build your application. The image you run in production only needs what's required to run it. Multi-stage builds let you separate these:

```dockerfile
# Stage 1: build
FROM python:3.12 AS builder
WORKDIR /app

# Install build dependencies (compilers, headers, etc.)
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt

# Stage 2: runtime — starts fresh from a smaller base image
FROM python:3.12-slim AS runtime
WORKDIR /app

# Copy only the installed packages from the builder stage
COPY --from=builder /install /usr/local
COPY . .

# Don't run as root
RUN useradd --system --no-create-home appuser
USER appuser

EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1"]
```

The `runtime` stage starts from `python:3.12-slim` (a minimal base image) and copies only the installed packages from the builder stage. The gcc compiler, build headers, and anything else used only during the install phase never makes it into the final image.

For a typical FastAPI service, this reduces image size from 800MB–1.2GB to 150–300MB. Smaller images pull faster, cold starts are quicker, and less surface area means fewer potential vulnerabilities.

* * *

## The .dockerignore File

Before Docker builds an image, it sends the build context (your project directory) to the Docker daemon. Without a `.dockerignore`, it sends everything — including `node_modules`, `.git`, `__pycache__`, local `.env` files, and any large data files in your directory.

```plaintext
# .dockerignore
.git
.env
.env.*
__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
htmlcov/
dist/
*.egg-info/
.venv/
venv/
```

A missing `.dockerignore` on a project with a large `.git` history can add 10–30 seconds to build time and send sensitive local files (`.env`) into the build context unnecessarily. Add it before your first build, not after.

* * *

## Cloud Run Specific: The Container Contract

Cloud Run expects containers to follow a specific contract:

**Listen on the PORT environment variable.** Cloud Run sets `PORT` (default 8080). Don't hardcode a port:

```dockerfile
# ❌ Hardcoded port
CMD ["uvicorn", "main:app", "--port", "8080"]

# ✅ Use the PORT env var
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "${PORT:-8080}"]
```

Or handle it in application code:

```python
import os
port = int(os.environ.get("PORT", 8080))
uvicorn.run(app, host="0.0.0.0", port=port)
```

**Start up fast.** Cloud Run routes traffic to your container only after it passes the health check. A slow startup delays when your instance can serve traffic. Defer expensive initialisation (loading ML models, warming caches) to background tasks where possible, or use lifespan events to parallelise startup operations.

**Handle SIGTERM gracefully.** Cloud Run sends SIGTERM before terminating an instance. Uvicorn handles this correctly by default — in-flight requests complete before shutdown. If you're running a custom server or worker process, ensure it handles SIGTERM and drains gracefully.

* * *

## Practical Dockerfile for a FastAPI Service

Combining everything above:

```dockerfile
FROM python:3.12 AS builder
WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt


FROM python:3.12-slim AS runtime
WORKDIR /app

COPY --from=builder /install /usr/local

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 && rm -rf /var/lib/apt/lists/*

COPY . .

RUN useradd --system --no-create-home --uid 1001 appuser && \
    chown -R appuser:appuser /app
USER appuser

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", \
     "--workers", "1", "--loop", "uvloop", "--timeout-keep-alive", "30"]
```

`PYTHONUNBUFFERED=1` ensures Python output goes directly to stdout without buffering — essential for Cloud Logging to receive logs in real time. `PYTHONDONTWRITEBYTECODE=1` stops Python from writing `.pyc` files, reducing image size slightly and avoiding permission issues on read-only filesystems.

* * *

## The Three Things to Check First

When a Docker issue comes up, these cover 80% of cases:

**Build is slow?** Check instruction order — expensive operations (pip install, npm install) should come before frequently-changing files (application code). Check `.dockerignore` — you may be sending a large build context.

**Image is large?** Add a multi-stage build. Check the base image — `python:3.12-slim` is 60MB vs `python:3.12` at 350MB. Check for unnecessary build tools in the final stage.

**Works locally but not in Cloud Run?** Check PORT handling. Check that the process listens on `0.0.0.0`, not `127.0.0.1`. Check that the startup time is within Cloud Run's health check timeout.

Everything else in Docker is variation on these themes.
