The 2 AM Nightmare: “But It Works on My Machine!”
I vividly remember a night on call when the system kept throwing 500 errors immediately after deployment. The FastAPI container log showed just one line: sqlalchemy.exc.ProgrammingError: (psycopg2.errors.UndefinedTable) relation "users" does not exist.
Everything was running smoothly locally. The problem was that I forgot to run the Alembic migrations during deployment. To make matters worse, that clunky 1.2GB Dockerfile made pulling a new image to fix the bug feel like an eternity.
If you’re dealing with sluggish containers, multi-gigabyte images, or are unsure how to handle Alembic within Docker, this article is for you. These are hard-earned lessons from dozens of production “battles.”
Why “Instant” Dockerfiles Cause Trouble
Most of us start with a simple Dockerfile like this:
FROM python:3.11
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
It looks fine on the surface, but in reality, it’s a “time bomb” with three major issues:
- Massive size: The image contains compilers, pip cache, and junk files.
- Poor security: Running containers as root is extremely dangerous if a hacker compromises the app.
- Out-of-sync DB: The database schema doesn’t automatically update when you update your code.
Multi-stage Build Strategy: Slimming Down from 1GB to 150MB
To solve the size issue, Multi-stage builds are the top choice. The idea is simple: use a tool-heavy image to build dependencies, then only move the essentials to a “ultra-light” (slim) image for runtime.
A quick tip for debugging Docker APIs: If you need to format JSON responses quickly, you can paste them into toolcraft.app/en/tools/developer/json-formatter. It helps reformat data lightning-fast without needing clunky extensions.
Here is the optimized Dockerfile configuration I typically use for real-world projects:
# Stage 1: Builder - Install compilers and build wheels
FROM python:3.11-slim as builder
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
RUN apt-get update && apt-get install -y --no-install-recommends gcc python3-dev libpq-dev
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
# Stage 2: Final - Ultra-lean production image
FROM python:3.11-slim
WORKDIR /app
# Create a dedicated user for security, avoid using root
RUN addgroup --system app && adduser --system --group app
# Install only necessary runtime libraries
RUN apt-get update && apt-get install -y libpq-dev && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .
RUN pip install --no-cache /wheels/*
COPY . .
# Grant permissions to the app user
RUN chown -R app:app /app
USER app
CMD ["/app/entrypoint.sh"]
Handling Migrations with Alembic: Full Automation
Never wait until after deployment to manually type alembic upgrade head. Instead, push it into an entrypoint.sh file. This file ensures the database is always ready and on the correct version before FastAPI starts.
#!/bin/sh
echo "Checking Database connection..."
# You should use a wait-for-it.sh script to ensure the DB is ready
echo "Running migrations..."
alembic upgrade head
echo "Starting server..."
exec "$@"
Remember to grant execution permissions: chmod +x entrypoint.sh. Without this step, your container will throw a “Permission denied” error immediately.
Docker Compose: Separating Dev and Production
When coding locally, we need --reload so the container automatically picks up changes whenever we hit Ctrl+S. However, on Production, --reload is a big no-no as it reduces performance and causes instability.
The solution is a flexible docker-compose.yml:
services:
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
- POSTGRES_USER=myuser
- POSTGRES_PASSWORD=mypass
- POSTGRES_DB=fastapi_db
web:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
- .:/app
ports:
- "8000:8000"
env_file:
- .env
depends_on:
- db
volumes:
postgres_data:
Note: In your .env file, change DATABASE_URL from localhost to db. This is the service name in the compose file that allows containers to find each other within the internal network.
Upgrading to Gunicorn for Production
When going live, replace uvicorn with Gunicorn to take advantage of the multi-worker mechanism. Gunicorn manages processes exceptionally well. If one worker hangs, it will automatically restart another to keep the app alive.
gunicorn -w 4 -k uvicorn.workers.UvicornWorker app.main:app --bind 0.0.0.0:8000
The standard formula for calculating the number of workers is: (2 x number of CPU cores) + 1. For example, if the server has 2 cores, you should set 5 workers.
Standard Workflow for Modifying SQLAlchemy Models
To avoid schema mismatch errors, follow these 5 steps:
- Update the model in your Python code.
- Run
docker-compose exec web alembic revision --autogenerate -m "description of changes". - Double-check the file in the
alembic/versionsfolder to ensure everything is correct. - Commit both the code and the new migration file to Git.
- When deploying,
entrypoint.shwill handle the rest on the server.
Dockerizing FastAPI isn’t just about copy-pasting a few commands. It’s about how you optimize layers, secure user permissions, and synchronize data. Doing it right from the start helps you sleep soundly without worrying about those midnight calls.

