Deploying Web Applications with Docker, Gunicorn, and Nginx: A Production-Ready Guide
Deploying web applications to production requires more than just running your code on a server. You need proper isolation, scalability, and a robust architecture that can handle real-world traffic. The combination of Docker, Gunicorn, and Nginx has become the industry standard for deploying Python web applications, and for good reason.
In this comprehensive guide, we’ll explore how these three technologies work together to create a production-ready deployment stack, and I’ll walk you through implementing it step by step.
Understanding the Stack
The Three Components
Docker: Containerization platform that packages your application with all its dependencies into a portable, isolated container.
Gunicorn: WSGI HTTP server that runs your Python web application (Flask, Django, etc.) and handles multiple concurrent requests.
Nginx: High-performance reverse proxy and web server that sits in front of Gunicorn, handling SSL/TLS, load balancing, and static file serving.
How They Work Together
Client Request
โ
Nginx (Port 80/443)
โ
Gunicorn (Port 8000)
โ
Python Application
โ
Database/External Services
- Client makes a request to your domain
- Nginx receives the request on port 80/443
- Nginx forwards the request to Gunicorn on port 8000
- Gunicorn runs your Python application
- Application processes the request and returns a response
- Nginx sends the response back to the client
Why This Stack?
- Isolation: Docker ensures your app runs the same everywhere
- Performance: Gunicorn efficiently handles concurrent requests
- Scalability: Nginx can load balance across multiple Gunicorn instances
- Security: Nginx provides SSL/TLS termination and acts as a security layer
- Simplicity: Each component has a single responsibility
Part 1: Creating a Docker Container
Step 1: Create a Sample Flask Application
# app.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route('/')
def hello():
return jsonify({'message': 'Hello from Flask!'})
@app.route('/health')
def health():
return jsonify({'status': 'healthy'})
if __name__ == '__main__':
app.run()
Step 2: Create a Requirements File
# requirements.txt
Flask==2.3.0
Gunicorn==20.1.0
Step 3: Write a Dockerfile
# Dockerfile
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app.py .
# Expose port (for documentation, Gunicorn will listen on this)
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
# Run Gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--worker-class", "sync", "--timeout", "60", "app:app"]
Dockerfile Explanation:
FROM python:3.11-slim: Use lightweight Python imageWORKDIR /app: Set working directory inside containerRUN apt-get update: Install system dependenciesCOPY requirements.txt: Copy dependencies fileRUN pip install: Install Python packagesCOPY app.py: Copy application codeEXPOSE 8000: Document that app listens on port 8000HEALTHCHECK: Define health check for container orchestrationCMD: Command to run when container starts
Step 4: Build and Test the Docker Image
# Build the image
docker build -t my-flask-app:1.0 .
# Run the container
docker run -p 8000:8000 my-flask-app:1.0
# Test the application
curl http://localhost:8000/
Part 2: Configuring Nginx
Nginx Configuration
# nginx.conf
upstream gunicorn {
# Define the Gunicorn server(s)
server gunicorn:8000;
# For load balancing across multiple instances:
# server gunicorn1:8000;
# server gunicorn2:8000;
# server gunicorn3:8000;
}
server {
listen 80;
server_name _;
client_max_body_size 20M;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Serve static files directly (if applicable)
location /static/ {
alias /app/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Proxy requests to Gunicorn
location / {
proxy_pass http://gunicorn;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
# Health check endpoint
location /health {
proxy_pass http://gunicorn;
access_log off;
}
}
Key Nginx Directives:
upstream: Defines backend serversproxy_pass: Forwards requests to Gunicornproxy_set_header: Passes important headers to backendadd_header: Adds security headers to responseslocation: Defines URL patterns and their handling
Dockerfile for Nginx
# Dockerfile.nginx
FROM nginx:alpine
# Copy custom Nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1
# Run Nginx
CMD ["nginx", "-g", "daemon off;"]
Part 3: Docker Compose Orchestration
Docker Compose Configuration
# docker-compose.yml
version: '3.8'
services:
# Gunicorn application server
gunicorn:
build:
context: .
dockerfile: Dockerfile
container_name: my-app-gunicorn
environment:
- FLASK_ENV=production
- PYTHONUNBUFFERED=1
volumes:
- ./app.py:/app/app.py # For development; remove in production
expose:
- 8000
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
- app-network
# Resource limits
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
# Nginx reverse proxy
nginx:
build:
context: .
dockerfile: Dockerfile.nginx
container_name: my-app-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./ssl:/etc/nginx/ssl:ro # For SSL certificates
depends_on:
- gunicorn
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
- app-network
networks:
app-network:
driver: bridge
Running with Docker Compose
# Start all services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
# Rebuild images
docker-compose up -d --build
# Scale Gunicorn instances
docker-compose up -d --scale gunicorn=3
Part 4: Production Considerations
SSL/TLS Configuration
# nginx.conf with SSL
server {
listen 80;
server_name example.com;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
# SSL certificates
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# HSTS header
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Rest of configuration...
}
Environment Variables
# docker-compose.yml with environment variables
services:
gunicorn:
environment:
- FLASK_ENV=production
- DATABASE_URL=postgresql://user:pass@db:5432/myapp
- SECRET_KEY=${SECRET_KEY}
- DEBUG=False
Logging and Monitoring
# Dockerfile with logging
FROM python:3.11-slim
WORKDIR /app
# Install logging dependencies
RUN pip install python-json-logger
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
# Configure logging
ENV PYTHONUNBUFFERED=1
CMD ["gunicorn", \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"--access-logfile", "-", \
"--error-logfile", "-", \
"--log-level", "info", \
"app:app"]
Resource Limits and Optimization
# docker-compose.yml with optimization
services:
gunicorn:
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '1'
memory: 512M
environment:
- GUNICORN_WORKERS=4
- GUNICORN_THREADS=2
- GUNICORN_WORKER_CLASS=gthread
Best Practices
1. Use Multi-Stage Builds
# Multi-stage Dockerfile for smaller images
FROM python:3.11 as builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY app.py .
ENV PATH=/root/.local/bin:$PATH
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
2. Security Best Practices
# Run as non-root user
FROM python:3.11-slim
RUN useradd -m -u 1000 appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN chown -R appuser:appuser /app
USER appuser
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
3. Health Checks
# docker-compose.yml with comprehensive health checks
services:
gunicorn:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
4. Graceful Shutdown
# Handle signals properly
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
# Use exec form to ensure signals are passed to Gunicorn
CMD ["gunicorn", \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"--timeout", "60", \
"--graceful-timeout", "30", \
"app:app"]
Common Pitfalls and Troubleshooting
Pitfall 1: Gunicorn Not Responding
# Check if Gunicorn is running
docker-compose ps
# View Gunicorn logs
docker-compose logs gunicorn
# Test connection
docker-compose exec nginx curl http://gunicorn:8000/
Pitfall 2: Nginx 502 Bad Gateway
Causes:
- Gunicorn not running
- Gunicorn crashed
- Network connectivity issue
Solution:
# Check Gunicorn health
docker-compose exec gunicorn curl http://localhost:8000/health
# Restart services
docker-compose restart gunicorn nginx
# Check logs
docker-compose logs --tail=50
Pitfall 3: Static Files Not Serving
# Ensure static files are configured correctly
location /static/ {
alias /app/static/;
expires 30d;
}
Pitfall 4: High Memory Usage
# Limit workers and threads
CMD ["gunicorn", \
"--bind", "0.0.0.0:8000", \
"--workers", "2", \
"--worker-class", "sync", \
"--max-requests", "1000", \
"--max-requests-jitter", "100", \
"app:app"]
Deployment Checklist
- Dockerfile builds successfully
- Application runs in container
- Nginx configuration is valid
- Docker Compose file is correct
- Health checks are configured
- SSL/TLS certificates are in place
- Environment variables are set
- Resource limits are defined
- Logging is configured
- Security headers are added
- Non-root user is used
- Graceful shutdown is implemented
- Monitoring and alerting are set up
Conclusion
The Docker, Gunicorn, and Nginx stack provides a robust, scalable foundation for deploying Python web applications. By understanding how each component works and following best practices, you can build production-ready deployments that are secure, performant, and maintainable.
Key Takeaways
- Docker provides isolation and consistency across environments
- Gunicorn efficiently runs your Python application with multiple workers
- Nginx acts as a reverse proxy, handling SSL/TLS and load balancing
- Docker Compose orchestrates multiple containers as a single application
- Health checks ensure your services are running correctly
- Security should be built in from the start (SSL/TLS, security headers, non-root user)
- Monitoring and logging are essential for production systems
- Resource limits prevent runaway containers from consuming all system resources
Start with this foundation, monitor your application in production, and iterate based on real-world performance and requirements. Happy deploying!
Comments