23.4. Deploying with Docker
Table of Contents
Overview
Deploying with Docker means packaging your backend application and everything it needs into a reusable, portable unit called an image, then running it as a container on any machine that has Docker installed. This chapter focuses on how to take a backend app that already runs locally and ship it using Docker in a simple, reliable way.
You will not learn Docker basics here, like what an image or container is in theory, because that is covered in the Docker section. Instead, you will see concrete deployment flows and patterns that are specific to running real backend services in production-like environments.
Typical Docker Deployment Flow
A simple backend deployment workflow with Docker often looks like this:
- Write a Dockerfile for your backend.
- Build a Docker image on your machine or in CI.
- Run and test the container locally.
- Push the image to a container registry.
- Pull and run the image on a server or cloud platform.
Let us go step by step with a concrete example using a FastAPI app, but the pattern is similar for other frameworks and languages.
Example project layout
Assume a small FastAPI project:
app/
main.py
requirements.txt
Dockerfile
app/main.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
app/requirements.txt:
fastapi
uvicorn[standard]Writing a Production-Friendly Dockerfile
You probably saw simple Dockerfiles earlier. For deployment, you want them to be:
- Reproducible
- Small
- Secure enough for production use
- Efficient to build and rebuild
A common pattern is:
- Use an official base image
- Install only what you need
- Copy your code
- Set a non-root user
- Run the application server
Here is a typical production-style Dockerfile for a FastAPI app:
# 1. Base image
FROM python:3.12-slim AS base
# 2. Environment configuration
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# 3. System dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# 4. Work directory
WORKDIR /app
# 5. Install Python dependencies
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 6. Copy the application code
COPY app/ .
# 7. Create a non-root user
RUN useradd -m appuser
USER appuser
# 8. Expose port and set default command
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Key ideas:
PYTHONDONTWRITEBYTECODEandPYTHONUNBUFFEREDhelp avoid extra files and ensure logs flush immediately.- Only required system packages are installed.
- Dependencies are installed before copying all code, which lets Docker cache the slow
pip installstep when code changes butrequirements.txtdoes not. - A non-root user runs the app, which is safer.
Important: Never run production containers as root unless absolutely necessary. Use USER to switch to a non-root user for better security.
For a different backend language, the pattern is similar:
- Node: use
node:XX-alpine, copypackage.json, runnpm install, then copy source. - Go: build a binary in a builder image, copy the binary into a small runtime image.
Building and Running the Image Locally
Once the Dockerfile is ready, you can build the image.
From the project root:
docker build -t myfastapi-app:latest .-t myfastapi-app:latestgives the image a name and tag..is the build context, usually the project root.
You can list images:
docker imagesThen run a container:
docker run -d \
--name myfastapi-container \
-p 8000:8000 \
myfastapi-app:latestExplanation:
-druns in detached mode (in the background).--nameassigns a container name.-p 8000:8000maps host port 8000 to container port 8000.myfastapi-app:latestis the image to run.
Check logs:
docker logs myfastapi-containerTest the health endpoint:
curl http://localhost:8000/health
# {"status": "ok"}Stop and remove the container when done:
docker stop myfastapi-container
docker rm myfastapi-containerRule: Always test the container locally before pushing the image or deploying to a server. If it does not work locally, it will not work in production.
Using Environment Variables in Docker Deployment
In development you might store settings in .env files or environment variables. For deployment, you must avoid hard coding secrets or environment-specific values in the image.
Common configuration values:
- Database URL
- Secret keys
- Debug flags
- External service URLs
Modify your app to read from environment variables. Example main.py:
import os
from fastapi import FastAPI
app = FastAPI()
ENV_NAME = os.getenv("ENV_NAME", "development")
@app.get("/env")
def env():
return {"env": ENV_NAME}Run the container with environment variables:
docker run -d \
--name myfastapi-container \
-p 8000:8000 \
-e ENV_NAME=production \
myfastapi-app:latestYou can also use an env file:
.env:
ENV_NAME=staging
DATABASE_URL=postgresql://user:pass@db:5432/app
DEBUG=false
Run with --env-file:
docker run -d \
--name myfastapi-container \
-p 8000:8000 \
--env-file .env \
myfastapi-app:latestSecurity rule: Do not bake secrets into your Docker image. Use environment variables, secret managers, or encrypted files mounted at runtime.
Handling Static Files and File Storage
In deployment, your backend might serve static assets or handle user uploads. Docker images are immutable, and containers have ephemeral filesystems, so you must plan where data lives.
Static files bundled in the image
For versioned static assets built at build time, you can copy them into the image.
Example project layout:
app/
main.py
static/
logo.png
styles.cssDockerfile snippet:
COPY app/ /appThe static files travel with the image. When containers are replaced, static files are still available because they are part of the image.
User uploads and persistent data
User uploads must not be stored only inside the container filesystem. When the container is recreated or scaled, files would be lost.
Solution: use volumes to persist files outside the container.
Example: mount a host directory into the container:
docker run -d \
--name myfastapi-container \
-p 8000:8000 \
-v /srv/uploads:/app/uploads \
myfastapi-app:latest/srv/uploadsis a host directory on the server./app/uploadsis where the app writes files.- When the container is deleted, files remain on the host.
Table of storage patterns:
| Type of data | Where to store |
|---|---|
| Static assets | Inside the image, or dedicated static server |
| User uploads | Volumes or object storage (S3, etc.) |
| Logs | Stdout/stderr for Docker, or volumes/log services |
| Databases | External DB service, or dedicated DB container + volume |
Multi-Container Deployments with Docker Compose
In production, your backend rarely runs alone. You probably need:
- Backend application container
- Database container
- Cache (Redis) container
- Maybe a reverse proxy like Nginx
docker-compose lets you define and run multi-container applications using a single YAML file.
Example docker-compose.yml for a basic stack:
version: "3.9"
services:
app:
build: .
container_name: myfastapi-app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/app
- ENV_NAME=production
depends_on:
- db
db:
image: postgres:16
container_name: myfastapi-db
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=app
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:Run everything:
docker compose up -dStop everything:
docker compose downThis is still deployment, even if it is on your local machine. The same pattern can be used on a remote server.
Pushing Images to a Container Registry
To deploy to a remote server or cloud, you must make your image available from somewhere that server can access. This is what a container registry is for.
Common registries:
- Docker Hub
- GitHub Container Registry
- GitLab Container Registry
- Amazon ECR, Google Artifact Registry, etc.
Example with Docker Hub
- Create a Docker Hub account.
- Login from your terminal:
docker login- Tag your image with your Docker Hub username:
docker tag myfastapi-app:latest yourname/myfastapi-app:1.0.0
docker tag myfastapi-app:latest yourname/myfastapi-app:latest- Push the image:
docker push yourname/myfastapi-app:1.0.0
docker push yourname/myfastapi-app:latestOn your remote server:
docker pull yourname/myfastapi-app:1.0.0
docker run -d \
--name myfastapi-app \
-p 80:8000 \
--env-file /etc/myfastapi.env \
yourname/myfastapi-app:1.0.0
Rule: Always tag images with a version, not only latest. This makes rollbacks and reproducible deployments possible.
Deploying to a Linux Server with Docker
Here is a concrete end-to-end scenario.
You have:
- A Linux server with Docker installed.
- A pushed image
yourname/myfastapi-app:1.0.0. - An
.envfile on the server at/etc/myfastapi.env.
Contents of /etc/myfastapi.env:
ENV_NAME=production
DATABASE_URL=postgresql://prod_user:secret@prod-db:5432/app
SECRET_KEY=super-secret-production-keySteps:
- Pull the image:
docker pull yourname/myfastapi-app:1.0.0- Run the container:
docker run -d \
--name myfastapi-app \
--restart unless-stopped \
-p 80:8000 \
--env-file /etc/myfastapi.env \
yourname/myfastapi-app:1.0.0Explanation:
--restart unless-stoppedensures the container restarts on server reboot or failure.-p 80:8000exposes the app on standard HTTP port 80.
- Check logs:
docker logs -f myfastapi-app- Test from outside:
From your local machine:
curl http://your-server-ip/health
If you see {"status":"ok"}, your app is deployed with Docker.
Zero-Downtime Style Updates with Docker
To update your app without noticeable downtime, you can deploy a new version beside the old one and then switch traffic.
Simple version for a single server and no reverse proxy:
- Build and push a new image:
yourname/myfastapi-app:1.1.0. - On the server, start a new container on another port:
docker run -d \
--name myfastapi-app-v2 \
-p 8001:8000 \
--env-file /etc/myfastapi.env \
yourname/myfastapi-app:1.1.0- Test the new version:
curl http://your-server-ip:8001/health- If OK, change your reverse proxy (Nginx, etc.) or load balancer configuration to forward traffic to port 8001 instead of 8000.
- Reload the proxy configuration.
- Stop the old container:
docker stop myfastapi-app
docker rm myfastapi-appIn a more advanced setup, a load balancer can keep both versions running for a while and drain connections from the old one.
Log Management in Deployed Containers
In production you need logs for debugging and monitoring. With Docker, the recommended pattern is:
- Your application writes logs to stdout and stderr.
- Docker captures those logs.
- A logging system collects them from Docker.
You do not usually write logs to files inside the container for deployment.
FastAPI example with Python logging:
import logging
logger = logging.getLogger("myapp")
logger.setLevel(logging.INFO)
app = FastAPI()
@app.get("/health")
def health():
logger.info("Health check called")
return {"status": "ok"}In the container:
docker logs -f myfastapi-appIn production, you might connect Docker logs to tools like:
journaldon Linux- ELK stack (Elasticsearch, Logstash, Kibana)
- Loki and Grafana
- Cloud logging services
Common Pitfalls When Deploying with Docker
Here are frequent mistakes and how to avoid them:
| Pitfall | Problem | Fix |
|---|---|---|
| Hard coded config in image | Cannot change settings without rebuild | Use environment variables, config files mounted at runtime |
| Storing uploads in container | Data lost on container replacement | Use Docker volumes or external storage |
| Running as root | Higher security risk | Use USER in Dockerfile |
Only using latest tag | Hard to know which version is running, difficult rollback | Use versioned tags (1.0.0, 2024-08-01, etc.) |
| Huge images | Slow deploy, big network transfer | Use slim base images, multi-stage builds, remove build tools |
| No health checks | No automatic detection of failing containers | Add health endpoints and configure health checks (proxy/orchestration) |
| Debug mode in production | Security issues, performance overhead | Make debug mode configurable and disabled by default |
Checklist: Before calling a Docker deployment "production ready", verify:
- No secrets in the image.
- Non-root user is used.
- Configurable via environment variables.
- Logs go to stdout/stderr.
- Persistent data is not stored only inside containers.
Summary
In this chapter you saw how to:
- Turn a backend app into a production-friendly Docker image.
- Build and test the image locally.
- Run containers with configuration via environment variables.
- Handle static files and persistent storage with volumes.
- Use Docker Compose for multi-container setups.
- Push images to a container registry and pull them on a server.
- Deploy and update your backend with minimal downtime.
- Avoid common mistakes when deploying with Docker.
These patterns form the foundation for more advanced deployment tools and orchestrators, such as Kubernetes or managed container services. Once you are comfortable deploying with plain Docker on a single server, those tools will be easier to understand.
Views: 7
KAHIBARO