FastAPI has become one of the most popular Python web frameworks for building APIs — and for good reason. It's fast (on par with Node.js and Go), has automatic OpenAPI documentation, and uses Python type hints for validation.
But deploying FastAPI to production is where many developers get stuck. Should you use Docker? Gunicorn or Uvicorn? How do you handle PostgreSQL, Redis, and SSL in production?
This guide walks through a complete FastAPI production deployment on a UK VPS — the same infrastructure we use at Hostingowy for our own API services. By the end, you'll have a production-ready FastAPI stack with Docker, Nginx reverse proxy, PostgreSQL, CI/CD, and monitoring — all running on UK-based hardware with GDPR compliance.
Before we dive into the technical setup, let's address the where. Deploying your FastAPI application on a UK VPS gives you:
At Hostingowy, all our VPS plans include NVMe SSD storage, KVM-based isolation, and free TLS certificates via Let's Encrypt — making it an ideal platform for FastAPI deployments.
Before starting, you'll need:
Here's the project structure we'll use:
fastapi-production/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI application entry point
│ ├── api/
│ │ ├── __init__.py
│ │ ├── v1/ # API version 1 routes
│ │ │ ├── __init__.py
│ │ │ ├── endpoints/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── users.py
│ │ │ │ └── items.py
│ │ │ └── router.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py # Settings management
│ │ ├── database.py # Database connection
│ │ └── security.py # Auth and security
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ └── services/ # Business logic
├── Dockerfile
├── docker-compose.yml # Production compose file
├── docker-compose.dev.yml # Development compose file
├── .env.example
├── requirements.txt
├── nginx/
│ └── app.conf # Nginx configuration
└── scripts/
├── deploy.sh # Deployment script
└── seed.py # Database seeding
# app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import api_router
from app.core.config import settings
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
)
# CORS middleware for production
app.add_middleware(
CORSMiddleware,
allow_origins=settings.BACKEND_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.API_V1_STR)
@app.get("/health")
async def health_check():
return {"status": "healthy", "version": settings.VERSION}
# app/core/config.py
from pydantic_settings import BaseSettings
from typing import List
import os
class Settings(BaseSettings):
PROJECT_NAME: str = "FastAPI Production"
VERSION: str = "1.0.0"
API_V1_STR: str = "/api/v1"
# Security
SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# Database
DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/app"
# Redis (optional, for caching)
REDIS_URL: str = "redis://localhost:6379/0"
# CORS
BACKEND_CORS_ORIGINS: List[str] = ["https://yourdomain.com"]
# Environment
ENVIRONMENT: str = "production"
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()
# Dockerfile
FROM python:3.12-slim AS builder
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Production stage
FROM python:3.12-slim
WORKDIR /app
# Copy Python packages from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy application code
COPY . .
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000
# Use multiple workers for production
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--proxy-headers", "--forwarded-allow-ips", "*"]
# docker-compose.yml
version: '3.8'
services:
app:
build: .
restart: unless-stopped
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
- SECRET_KEY=${SECRET_KEY}
- ENVIRONMENT=production
- BACKEND_CORS_ORIGINS=${CORS_ORIGINS}
env_file:
- .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
- static_data:/app/static
networks:
- internal
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS}
- POSTGRES_DB=${DB_NAME}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- internal
deploy:
resources:
limits:
memory: 256M
cpus: '0.25'
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/app.conf:/etc/nginx/conf.d/default.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
- static_data:/var/www/static:ro
depends_on:
- app
networks:
- internal
deploy:
resources:
limits:
memory: 128M
cpus: '0.1'
certbot:
image: certbot/certbot
restart: unless-stopped
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
internal:
driver: bridge
volumes:
postgres_data:
redis_data:
static_data:
# nginx/app.conf
upstream fastapi_app {
server app:8000;
keepalive 32;
}
server {
listen 80;
server_name yourdomain.com api.yourdomain.com;
# Redirect to HTTPS
location / {
return 301 https://$host$request_uri;
}
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
server {
listen 443 ssl http2;
server_name yourdomain.com api.yourdomain.com;
# SSL configuration
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
# Static files
location /static/ {
alias /var/www/static/;
expires 7d;
add_header Cache-Control "public, immutable";
}
# API proxy
location / {
limit_req zone=api burst=50 nodelay;
proxy_pass http://fastapi_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
# Buffer settings
proxy_buffering on;
proxy_buffer_size 8k;
proxy_buffers 8 8k;
# Request size limit
client_max_body_size 10M;
}
# API documentation (restrict in production)
location /docs {
allow 10.0.0.0/8;
allow 172.16.0.0/12;
deny all;
proxy_pass http://fastapi_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Access and error logs
access_log /var/log/nginx/api_access.log;
error_log /var/log/nginx/api_error.log;
}
# app/core/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
async_session_factory = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session_factory() as session:
try:
yield session
finally:
await session.close()
# .github/workflows/deploy.yml
name: Deploy FastAPI to Production
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
run: |
pytest --cov=app --cov-report=xml
- name: Run linting
run: |
pip install ruff
ruff check app/
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push Docker image
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/fastapi-app
docker compose pull
docker compose up -d --force-recreate app
docker system prune -f
- name: Health check
run: |
sleep 10
curl -f https://yourdomain.com/health || exit 1
# Run on the VPS before deployment
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Fail2Ban for SSH protection
apt-get install fail2ban
systemctl enable fail2ban
# Automatic security updates
apt-get install unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades
# app/core/security.py
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
# Rate limiting middleware
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
# Add to requirements.txt: prometheus-client
from prometheus_client import Counter, Histogram, generate_latest
from fastapi import Response
import time
REQUEST_COUNT = Counter('http_requests_total', 'Total HTTP requests', ['method', 'endpoint', 'status'])
REQUEST_DURATION = Histogram('http_request_duration_seconds', 'HTTP request duration', ['method', 'endpoint'])
@app.middleware("http")
async def monitor_requests(request, call_next):
start_time = time.time()
response = await call_next(request)
duration = time.time() - start_time
REQUEST_COUNT.labels(
method=request.method,
endpoint=request.url.path,
status=response.status_code
).inc()
REQUEST_DURATION.labels(
method=request.method,
endpoint=request.url.path
).observe(duration)
return response
@app.get("/metrics")
async def metrics():
return Response(content=generate_latest(), media_type="text/plain")
# app/core/logging.py
import logging
import json
from datetime import datetime
class JSONFormatter(logging.Formatter):
def format(self, record):
log_entry = {
"timestamp": datetime.utcnow().isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
}
if hasattr(record, 'request_id'):
log_entry["request_id"] = record.request_id
if record.exc_info:
log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry)
# Configure logging
logger = logging.getLogger("fastapi")
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logger.addHandler(handler)
logger.setLevel(logging.INFO)
#!/bin/bash
# scripts/deploy.sh - Run on VPS after initial setup
set -e
echo "🚀 Deploying FastAPI application..."
# Pull latest code
cd /opt/fastapi-app
git pull origin main
# Backup database
docker exec $(docker ps -qf "name=db") pg_dump -U $DB_USER $DB_NAME > backup_$(date +%Y%m%d_%H%M%S).sql
# Build and restart
docker compose build --no-cache app
docker compose up -d
# Wait for health check
echo "⏳ Waiting for health check..."
for i in {1..30}; do
if curl -sf http://localhost:8000/health > /dev/null 2>&1; then
echo "✅ Application is healthy!"
break
fi
sleep 2
done
# Clean up old images
docker image prune -f
echo "✅ Deployment complete!"
To get the most out of your UK VPS for FastAPI production:
| Parameter | Recommended Setting | Why |
|---|---|---|
| Uvicorn workers | 2 × CPU cores | Optimal for async I/O workloads |
| PostgreSQL shared_buffers | 25% of RAM | Balanced for read-heavy APIs |
| Redis maxmemory | 10% of RAM | Cache API responses and session data |
| Nginx worker_connections | 1024 | Handles concurrent connections efficiently |
| Kernel TCP tuning | net.ipv4.tcp_tw_reuse=1 | Reduce connection overhead under load |
When deploying FastAPI on a UK VPS, pay attention to:
When your API outgrows a single VPS, here's the scaling path:
Deploying FastAPI on a UK VPS gives you the perfect balance of performance, control, and compliance. With Docker, Nginx, PostgreSQL, and CI/CD in place, you have a production-ready stack that can scale from zero to thousands of requests per second.
The entire setup — from first SSH connection to running API — takes about 2 hours on a fresh VPS. After that, deployments via CI/CD take under 2 minutes.
Get a UK VPS with NVMe storage, KVM isolation, and free TLS certs. First month free with code LAUNCH100.