32.12. Production Deployment
Table of Contents
Understanding Production Deployment
Production deployment is the step where your backend stops being a local experiment and becomes a real service that users depend on. In the final project, this is where you take your already built and tested application and run it on real infrastructure, in a safe and repeatable way.
This chapter focuses on the practical aspects that are specific to deploying a production backend, not on building the application itself or configuring HTTPS and domains, which are covered elsewhere.
Goals of a Production Deployment
When you deploy to production, you are trying to achieve more than just “it runs.” A good deployment:
- Is repeatable: you can deploy the same version again in the same way.
- Is predictable: you know what is going to change, and where.
- Is observable: you can see if it worked and how the application behaves after.
- Minimizes downtime and risk.
Key production rule:
A build that is deployed to production must be the same artifact that passed automated tests in CI, without manual changes.
Think of deployment as a pipeline: code → build artifact → tested artifact → deployed artifact. You should not rebuild or edit the app manually on the server.
Environments: Local, Staging, Production
You will usually have at least three environments:
| Environment | Purpose | Who uses it |
|---|---|---|
| Local | Daily development and experimentation | Individual developers |
| Staging | Final testing before production | Devs, QA, sometimes PMs |
| Production | Real users and real data | End users and clients |
The same application should run in all three, but with different configuration: database URLs, secrets, debug flags, logging levels, etc.
For your final project, you can often skip staging if you are alone, but it is better to create at least a “staging-like” environment where you can safely test migrations and deployments on a server that is not serving real users.
Where to Deploy: Server Choices
There are three common options:
| Option | Example | Pros | Cons |
|---|---|---|---|
| Single VPS (virtual server) | DigitalOcean, Linode, Hetzner | Cheap, simple to understand | Manual scaling, more manual setup |
| PaaS platform | Render, Railway, Fly.io | Less ops work, easy deployment | Costs, more vendor lock-in |
| Container orchestrator | Kubernetes, ECS, Nomad | Scales well, advanced features | Complex to learn |
For this course and your final project, a single Linux VPS with Docker is a realistic and educational choice, so we will describe deployment in that context.
Preparing the Production Server
Before you deploy, your server should be prepared. This usually happens once, then rarely changes.
Typical steps on a fresh Linux VPS:
- Create a non-root user
- Add a user and give it sudo privileges.
- Use this user for deployments, not root.
- Set up SSH authentication
- Use SSH key pairs, not passwords.
- Optionally disable password login for better security.
- Install required software
At minimum:
- Docker
- Docker Compose (or similar)
- Git (if you pull code)
- Basic tools like
curl,htop,vim, etc.
- Configure firewall
- Allow SSH (port 22) and HTTP/HTTPS (ports 80 and 443).
- Block everything else by default if possible.
- Create directories for persistent data
Example structure:
/opt/yourapp/
app/ # application code or built artifacts
env/ # environment files (.env)
data/ # DB volumes, uploads, etc.
logs/ # application logs (if stored on disk)This is infrastructure work, not deployment itself, but deployment will assume this base is ready.
Deployment Artifacts
In modern backend deployments you rarely copy raw source code and run pip install directly on the server. Instead you create an artifact:
- A Docker image that contains your app and its dependencies.
- Or a tarball / zip with prebuilt code, if not using containers.
In this course you will generally use Docker.
Building Images for Production
Your application likely already has a Dockerfile. For production, you want:
- A small image.
- A non-root user inside the container if possible.
- Only the dependencies needed at runtime.
A simple multi-stage pattern (conceptual example):
# Builder stage: install dev tools, build dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN pip install --upgrade pip && pip install poetry \
&& poetry export -f requirements.txt --output requirements.txt
COPY . .
RUN pip install -r requirements.txt
# Runtime stage: minimal image
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12 /usr/local/lib/python3.12
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]In practice you should already have something similar from earlier Docker chapters. The important part for deployment: the same Docker image that you build in CI (or on your machine) is what you run in production.
A Minimal Production Stack
A typical production-like stack for your final project might include:
- FastAPI application, behind
- Uvicorn workers, managed by Gunicorn or by Uvicorn workers in a process manager
- PostgreSQL database
- Redis for caching and background jobs
- Nginx as reverse proxy and static file server (plus TLS terminator)
All of this can be described in a docker-compose.yml file.
A minimal example (simplified and generic):
version: "3.9"
services:
app:
image: your-docker-user/yourapp:1.0.0
env_file:
- ./env/production.env
depends_on:
- db
- redis
ports:
- "8000:8000"
command: >
uvicorn app.main:app
--host 0.0.0.0
--port 8000
--workers 4
restart: always
db:
image: postgres:16
environment:
POSTGRES_DB: yourapp
POSTGRES_USER: yourapp
POSTGRES_PASSWORD: change_me
volumes:
- ./data/postgres:/var/lib/postgresql/data
restart: always
redis:
image: redis:7
volumes:
- ./data/redis:/data
restart: always
In a real deployment, Nginx will sit in front of the app service, listen on ports 80 and 443 and forward requests to app:8000.
Environment-Specific Configuration
The same code will run in local, staging, and production, with different configuration.
Use environment variables to configure:
DATABASE_URLREDIS_URLSECRET_KEYDEBUGflagALLOWED_HOSTS- Any external API keys
You might have:
.env.localfor local.env.stagingon staging.env.productionon production
On the server, you typically place them in a directory like /opt/yourapp/env/production.env.
Never commit production secrets to Git.
Secrets must only exist in secure storage, such as environment variables, secret managers, or encrypted files outside your repository.
Your application configuration layer should read values from environment variables. For example, in a Pydantic settings class.
A Simple Manual Deployment Flow
Here is a concrete example of a manual but structured deployment using Docker and a VPS. This is enough for a small project.
Step 1: Build and push the image
On your development machine or in CI:
# Pull latest changes
git pull origin main
# Run tests
pytest
# Build image
docker build -t your-docker-user/yourapp:1.0.0 .
# Push to registry
docker push your-docker-user/yourapp:1.0.0
The tag 1.0.0 should match a Git tag or commit reference. Avoid using only latest because it is not explicit.
Step 2: Connect to the server
From your machine:
ssh youruser@your-server-ip
cd /opt/yourappStep 3: Pull the new image
On the server:
docker pull your-docker-user/yourapp:1.0.0
If you use docker-compose.yml with the image tag set, make sure it matches.
Step 4: Run migrations
Database migrations should be applied before switching traffic to the new version, but after you have the new code.
For example, if your app provides a migration command:
docker compose run --rm app alembic upgrade headThis uses the new image to run migrations.
Step 5: Restart the application service
Using Docker Compose:
docker compose up -d appThis recreates the app container with the new image while keeping the DB and Redis running.
Step 6: Verify the deployment
Check container status:
docker compose psInspect logs:
docker compose logs -f app
Trigger a simple health check. If you have a /health endpoint:
curl -f https://your-domain.com/healthIf it returns a successful status, your deployment is live.
Zero-Downtime Upgrade Strategy (Basic)
For high-traffic applications, you usually aim for zero downtime.
You can get close to zero downtime even with a single server and Docker:
- Nginx listens on port 80/443 and forwards to the
appcontainer. - When you run
docker compose up -d app: - Docker creates a new container from the new image.
- It stops and removes the old container after the new one is ready.
- Nginx holds the listening ports. It simply connects new requests to the new container.
During this transition, existing connections may be closed, but if your application is stateless and calls are short, users usually do not notice.
For more advanced setups you can:
- Run two app services, like
app_v1andapp_v2, and switch Nginx upstream from one to the other. - Use load balancers that support draining old instances.
These are beyond the scope of this course but follow the same idea: bring new version up, switch traffic, then take old version down.
Coordinating Migrations and Deployments
Database migrations are the part that often breaks deployments.
A safe pattern:
- Additive migrations first
- Add new columns or tables that the new version needs.
- Keep the old version compatible.
- Deploy new code that uses both old and new structures if needed.
- Switch application version
- Deploy new version of the app that can handle both old and new data format, or that uses the new structures.
- Cleanup migrations
- After the new version runs safely in production and old data paths are not used, remove deprecated columns or tables.
For small projects you can often migrate and deploy in one step, but still:
Always run migrations before starting the new application version that depends on them.
Never deploy code that expects a column that does not exist yet.
If a migration fails:
- Stop the deployment.
- Investigate and fix the migration.
- Apply the corrected migration before trying to deploy again.
Rolling Back a Deployment
No matter how careful you are, some deployments will introduce bugs.
You must have a plan to roll back.
Typical rollback options with Docker:
- Keep the previous image
You might have versions:
yourapp:1.0.0yourapp:1.1.0
If 1.1.0 is bad, you can:
- Change the image tag in
docker-compose.ymlback to1.0.0. - Run
docker compose up -d app.
- Roll back database migrations
This is harder. It only works if your migration tooling supports down migrations and your changes are reversible.
Example:
docker compose run --rm app alembic downgrade -1Some changes, such as dropping a column, cannot be easily reversed.
Because database rollback is risky, many teams prefer:
- Roll forward with a quick hotfix.
- Or temporarily disable certain features instead of rolling back schema.
For your final project, a simple rollback strategy is:
- Keep the last known good version and its tag.
- If the new version fails critically, roll back application containers only, and keep the schema unchanged, if possible.
Logging and Monitoring After Deployment
After deployment you need to check that the application is healthy.
Use the logging and monitoring tools from previous chapters, but pay attention to deployment-specific checks:
- Errors that appear only under production load.
- Slow queries or endpoints after schema changes.
- Container restarts, which can indicate crashes.
- Resource usage, like memory and CPU.
Right after a deployment you should:
- Tail logs for a few minutes:
docker compose logs -f app- Watch the system:
- Use tools like
htop,docker stats, or your monitoring dashboard. - Hit key API endpoints manually or with a smoke test script.
A smoke test is a very small set of automated checks that verifies the basic health of the service after deployment, for example:
GET /healthPOST /auth/loginwith a test userGET /some/critical/endpoint
If any of these fail, treat the deployment as failed and consider rolling back.
Common Production Deployment Pitfalls
Here are mistakes that cause trouble frequently:
| Pitfall | Safer alternative |
|---|---|
| Building images directly on the server | Build in CI or locally, then push and pull |
| Editing code manually on the server | Only deploy from Git commits |
Using latest tag everywhere | Use versioned tags like 1.0.0 or commit hash |
Running pip install in a running container | Build dependencies into the image |
Copying .env into the repo | Store .env files only on the server, not in Git |
| Skipping migrations | Always migrate before starting new app version |
| No health checks | Implement a /health endpoint and test it |
| No logs after deploy | Always look at logs after deployment |
Keep this list in mind when you design your final project deployment process.
Documenting Your Deployment Process
For the final project, pretend you are handing this project to another engineer. They should be able to deploy without guessing.
Create a DEPLOYMENT.md file that includes:
- Required environment variables and their meaning.
- How to build and tag the Docker image.
- How to push the image.
- How to update
docker-compose.ymlfor a new version. - How to run migrations.
- How to start or restart the stack.
- How to verify the deployment.
- How to roll back to the previous version.
For example, you might include:
# Deployment guide
1. Build and push image:
```bash
docker build -t your-docker-user/yourapp:VERSION .
docker push your-docker-user/yourapp:VERSION- On server:
cd /opt/yourapp
sed -i 's/yourapp:.*/yourapp:VERSION/' docker-compose.yml
docker pull your-docker-user/yourapp:VERSION
docker compose run --rm app alembic upgrade head
docker compose up -d app- Verify:
curl -f https://your-domain.com/healthViews: 7
KAHIBARO