Technical Guide Python API

Deploy FastAPI on UK VPS: Complete Production Setup Guide 2026

Published: July 20, 2026 • Reading time: 14 min • Topics: FastAPI, Python, Docker, Nginx, UK VPS

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.

1. Why Deploy FastAPI on a UK VPS?

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.

2. Prerequisites

Before starting, you'll need:

3. Project Structure

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

4. Building the FastAPI Application

4.1 Application Entry Point

# 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}

4.2 Configuration Management

# 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()

5. Docker Setup for Production

5.1 Dockerfile

# 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", "*"]

5.2 Production Docker Compose

# 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:

6. Nginx Configuration

# 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;
}

7. Database Setup with PostgreSQL

7.1 Async Database Connection

# 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()

8. CI/CD Pipeline with GitHub Actions

# .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

9. Security Hardening

9.1 VPS-Level Security

# 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

9.2 Application-Level Security

# 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)

10. Monitoring & Logging

10.1 Prometheus Metrics

# 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")

10.2 Structured Logging

# 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)

11. Deployment Script

#!/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!"

12. Performance Tuning for UK VPS

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

13. UK Compliance Considerations

When deploying FastAPI on a UK VPS, pay attention to:

14. Scaling Your FastAPI Application

When your API outgrows a single VPS, here's the scaling path:

  1. Vertical scaling: Upgrade to a larger VPS plan (more CPU, RAM, NVMe) — takes 5 minutes on Hostingowy
  2. Database separation: Move PostgreSQL and Redis to dedicated VPS instances
  3. Horizontal scaling: Deploy multiple app servers behind a load balancer
  4. CDN integration: Use Cloudflare or similar for static assets and API caching
  5. Managed services: Consider managed PostgreSQL and Redis for production-critical workloads

Conclusion

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.

🚀 Ready to deploy your FastAPI app on UK hardware?

Get a UK VPS with NVMe storage, KVM isolation, and free TLS certs. First month free with code LAUNCH100.

→ Start your free month at hostingowy.com

Related Guides