Most Docker tutorials stop at docker build and call it done. The real problems, the ones that actually cost you time, show up later. When you push to a registry. When CI tries to reproduce your laptop build. When the chip on your machine is not the chip on the server.
This is what broke while I was taking a FastAPI service from a working local Compose stack to something I would let CI ship. And why each fix works the way it does.
## Multi-stage builds are not optional once the image leaves your laptop
A single-stage Dockerfile that does pip install and COPY . . will build. It will also hand you a 400MB+ image for an API with three routes. Pip's cache, build tools, and the whole project directory end up inside a production artifact that never needed any of it.
The fix is structural, not clever. Build in one stage. Run in another. Copy across only the finished product.
My builder stage creates a virtualenv and installs dependencies into it. The runtime stage starts from a fresh copy of the same base image and copies exactly two things: that venv, and the application code. No compilers. No .git. No pip cache sitting in a layer you will pay to pull forever.
Final size: 170MB. The gap between that and a naive build is not a rounding error. It is the difference between an image your runners pull in two seconds and one that makes every pipeline feel slower than it should.
Do this every time as well: run as a non-root user inside the container.
RUN adduser --disabled-password --gecos '' appuser
USER appuser
depends_on does not mean what people think it means
Compose depends_on waits for a container to start. It says nothing about whether that container can do any work yet.
Postgres is the usual trap. The container process is up in under a second. The database is not ready to accept connections for a few seconds after that. Your app starts, tries to connect immediately, fails, then either crash-loops or burns retries depending on how you wrote the client.
The fix is depends_on with condition: service_healthy, paired with a real healthcheck. For Postgres, pg_isready is the right check.
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 5s
retries: 10
api:
depends_on:
db:
condition: service_healthy
Now the API waits until Postgres can answer a query, not until PID 1 exists.
This bug almost never shows up in a demo. Demos are slow enough, by accident, that the race does not fire. It shows up in CI, under load, or at 2am. Fix it before it has a ticket number.
The architecture mismatch nobody puts in the quickstart
Build on Apple Silicon, deploy to a standard cloud VM, and you will hit this. Your image is arm64. The server is amd64. Docker will refuse to run it, or worse, emulate it badly enough that the container crash-loops and looks like an application bug.
The signature I keep seeing: container stuck in Restarting (255), logs that do not mention the app at all. The failure is under the process you think you are debugging.
Build both architectures and push a manifest list, not a single flat image:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag ghcr.io/you/fastapi-service:latest \
--push .
The registry then serves the variant that matches the host pulling it. One tag. Correct binary on every machine. You stop discovering architecture mismatches after the deploy already ran.
If you only need the server's architecture, still be explicit:
docker buildx build --platform linux/amd64 --push .
Implicit "whatever my laptop is" is how this class of bug is born.
CI that blocks, not CI that narrates
A pipeline that runs lint, test, and build, then paints green checkmarks, is only half the job. The other half is proving those checks can stop a merge.
Split the work into separate jobs and make build depend on test. That part is easy. The part worth verifying is the failure path. Break an assertion on purpose. Push it. Watch test go red and build get skipped because its dependency never passed.
Then turn on branch protection with required status checks. That is what turns a red X into a greyed-out merge button. A pipeline that reports failures without enforcing them is just a slower way to learn that something is broken.
If you have not watched a bad commit get blocked, you do not know whether the pipeline works. You only know that it runs.
The permission error that is not about your YAML
Deploying from GitHub Actions to GHCR, you will eventually hit:
denied: permission_denied: write_package
You already scoped the workflow token. packages: write is in the file. The job still dies.
The cause is easy to miss. If the package was first pushed from a laptop with a personal token, the package does not automatically trust the repo's GITHUB_TOKEN. The workflow can ask for write. The package can still say no.
The fix is in the package settings, not in YAML. Open the package, go to Manage Actions access, link the repository, grant write. One-time change. A permissions: block cannot override it, because the restriction lives on the package, not in the workflow.
Same class of problem as the others. The error shows up in Actions. The lever is one screen over.
What actually matters
None of this is exotic. It is the gap between "I can write a Dockerfile" and "I can let a machine ship this."
Multi-stage builds are not a nice-to-have. They are how you stop shipping a workshop as a runtime.
Health checks are not paranoia. They are how you stop a startup race from becoming an outage.
Cross-platform builds are not an edge case if you develop on Apple Silicon and deploy to amd64, which is a normal setup now.
CI that cannot block a merge is monitoring, not control.
Registry permissions live on the package. The workflow file is not the whole story.
The pattern across all of it: the failure never shows up where you were looking. It shows up one layer down, after the part you tested already worked.
Swap `ghcr.io/you/fastapi-service` for your real image name before you publish. ``
Top comments (0)