Deploying FastAPI to a VPS: Part 1 - Building and Dockerizing
I'm starting this series to walk through how I deploy my FastAPI apps to a VPS. Getting stuff from your local machine to a live server can be a bit of a headache, so I figured I'd document my process using the tools I've found work best.
Tools I'm using:
- FastAPI: My go-to Python framework
- Docker: Because you have to know it😂
- Docker Compose: We'll use this in part 2 to manage multiple containers
- Gunicorn: The production server for Python apps
- Uvicorn: Needed for FastAPI's async magic, but we'll run it under Gunicorn's supervision
What we're covering in Part 1:
- Setting up a basic FastAPI app
- Testing locally with Gunicorn (the way we'll run it in production)
- Dockerizing everything with multi-stage builds to keep our images small
Step 1: Project Setup and Environment
First, let's get our project structure and virtual environment going:
# Create the project directory
mkdir fast-microservice
# Navigate into the new directory
cd fast-microservice
# Create a Python virtual environment
# (Use python3 -m venv venv if virtualenv command is not available)
virtualenv venv
# Activate the virtual environment
# On Windows use: .\venv\Scripts\activate
source venv/bin/activate
Step 2: Install Dependencies
We need a few packages to get started:
# Install necessary Python packages
pip install fastapi "uvicorn[standard]" gunicorn pydantic
# Freeze the installed packages and versions into requirements.txt
pip freeze > requirements.txt
Step 3: Create the FastAPI Application
Let's create our main app file:
touch main.py
Here's the code for our basic user management API:
from fastapi import FastAPI,HTTPException
from pydantic import BaseModel
from typing import List, Optional
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import logging
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Fast Microservice",
description="A simple FastAPI microservice for user management",
version="1.0.0",
)
class UserCreate(BaseModel):
name: str
email: str
# Use a separate model for output data (includes the ID)
class User(UserCreate):
id: int
# --- In-Memory Storage ---
# A simple list to act as our database for this example
# In a real app, you'd use a database.
users_db: List[User] = []
next_user_id = 1 # Simple ID counter
# --- CORS Middleware ---
# Allows requests from any origin (useful for frontend development)
# For production, you might want to restrict this to specific origins.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allows all origins
allow_credentials=True,
allow_methods=["*"], # Allows all methods (GET, POST, etc.)
allow_headers=["*"], # Allows all headers
)
# --- API Endpoints ---
@app.get("/health", summary="Check service health")
async def health_check():
"""
Simple health check endpoint.
"""
logger.info("Health check endpoint called")
return {"status": "ok"}
@app.post("/users", response_model=User, status_code=201, summary="Create a new user")
async def create_user(user_data: UserCreate):
"""
Creates a new user if email doesn't exist and limit is not reached.
"""
global next_user_id
logger.info(f"Attempting to create user with email: {user_data.email}")
# Simple validation examples
if len(users_db) >= 100:
logger.warning("User limit reached. Cannot create more users.")
raise HTTPException(status_code=400, detail="User limit reached")
if any(u.email == user_data.email for u in users_db):
logger.warning(f"Email {user_data.email} already exists.")
return JSONResponse(content={"error": "Email already exists"}, status_code=400)
new_user = User(id=next_user_id, **user_data.dict())
users_db.append(new_user)
next_user_id += 1 # Increment ID for the next user
logger.info(f"User {new_user.email} created successfully with ID {new_user.id}")
return new_user
@app.get("/users", response_model=List[User], summary="Get all users")
async def get_users():
"""
Retrieves a list of all created users.
"""
logger.info(f"Retrieving all {len(users_db)} users.")
return users_db
Step 4: Local Testing with Gunicorn
Before we move to the docker part, let's make sure our app works with Gunicorn:
gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000
Quick breakdown:
-
main:app
- points to the app variable in main.py -
--workers 4
- I usually go with 2x CPU cores + 1, but adjust as needed -
--worker-class uvicorn.workers.UvicornWorker
- this is the magic that lets Gunicorn handle FastAPI's async stuff -
--bind 0.0.0.0:8000
- listen on all interfaces (important for Docker)
Hit up http://localhost:8000/docs
in your browser to see the Swagger UI. You can test creating users and retrieving the list.
Once you're done, hit Ctrl+C
to stop the server.
Step 5: Dockerizing with a Multi-Stage Build
Now for the fun part - let's containerize our app with a multi-stage build to keep things lean:
touch Dockerfile
Here's my Dockerfile:
# --- Stage 1: Builder ---
# Use a specific Python version slim image as the base for building
FROM python:3.11-slim AS builder
LABEL stage=builder
# Set the working directory inside the container
WORKDIR /app
# Copy only the requirements file first to leverage Docker cache
COPY requirements.txt .
# Install Python dependencies
# Using --no-cache-dir reduces image size slightly
# Using --prefix=/install makes it easy to copy installed packages later
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# --- Stage 2: Runtime ---
# Use the same slim Python image for the final runtime environment
FROM python:3.11-slim
# Set the working directory
WORKDIR /app
# Copy installed packages from the builder stage's /install directory
COPY --from=builder /install /usr/local
# Copy the application code into the container
COPY . .
# Copy the startup script and make it executable
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
# Create a non-root user for security best practices
RUN addgroup --system nonroot && adduser --system --ingroup nonroot nonroot
USER nonroot
# Expose the port the application will run on
EXPOSE 8000
# Set environment variables (optional but good practice)
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# Command to run the application using the startup script
CMD ["/app/start.sh"]
Why multi-stage? Simple - I want the smallest possible production image. The first stage installs all dependencies, then the second stage only copies what we need, leaving behind all the build tools and cache. Plus, we run as a non-root user for better security.
Step 6: Create the Startup Script
Let's create a simple script to launch Gunicorn:
touch start.sh
And here's what goes in it:
#!/bin/bash
# Exit immediately if a command exits with a non-zero status.
set -e
# Print a message to the logs
echo "Starting application with Gunicorn..."
# Execute Gunicorn
exec gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000
Step 7: Build and Run the Docker Container
Finally, let's see this thing in action:
# Build the Docker image
docker build -t fast-microservice .
# Run a container from the image
docker run -d -p 8000:8000 --name fast-app fast-microservice
Check the logs to make sure everything started up correctly:
docker logs fast-app
Now try http://localhost:8000/docs
again - should work just like before, but now it's running in Docker!
When you're done playing around:
docker stop fast-app
docker rm fast-app
Wrapping Up
In the next part of this series, I'll show you how to use Docker Compose with Traefik as a reverse proxy to handle HTTPS and routing. This setup has served me well for managing multiple services on a single VPS.
Drop a comment if you run into any issues!