Deploying a FastAPI Application to a VPS

The blog application is now complete with authentication, PostgreSQL with Alembic migrations, S3 file storage for profile pictures, full async patterns, and unit testing. This production-ready application is ready for deployment to a server where real users can access it.

Two deployment approaches will be covered in this series. This tutorial demonstrates VPS (Virtual Private Server) deployment, revealing what happens under the hood. The next tutorial will cover containerized cloud deployment, a more managed approach. The VPS tutorial comes first because it teaches the fundamentals of web application deployment—skills that transfer to any VPS provider, whether Linode, Digital Ocean, AWS EC2, or others. Understanding these fundamentals simplifies debugging even when using managed services later. Not every application requires containerized deployment; a simple VPS often suffices.

This tutorial uses Linode (not sponsored, simply a familiar service). Any VPS provider works similarly—the concepts apply universally. A real domain name will be configured (IP-only websites are unprofessional), using myawesomeapp.com purchased through Namecheap. HTTPS will be enabled with a free SSL certificate, and basic security hardening will be implemented—an essential step when exposing servers to the internet. This creates a secure baseline every server should have.

Adding a Health Check Endpoint

Before configuring the server, add a health check endpoint to the application. Production platforms typically require health checks—load balancers use them to determine service availability, monitoring systems ping them regularly, and failed checks trigger traffic redirection or alerts. This pattern appears consistently in production applications.

For the blog application, the database is the critical component. If the database is down, nothing functions. The health check will verify database connectivity.

Open main.py and add an import:

from sqlalchemy import text

After the router includes, add the health check endpoint:

@app.get("/health", include_in_schema=False)  
async def health_check(db: Annotated[AsyncSession, Depends(get_db)]):  
    try:  
        await db.execute(text("SELECT 1"))  
        return {"status": "healthy"}  
    except Exception as exc:  
        raise HTTPException(  
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,  
            detail="Database connection failed"  
        ) from exc

This endpoint takes a database session from dependency injection and executes SELECT 1—an extremely lightweight query that doesn’t scan tables or read data. It simply checks whether the database is reachable and queries can execute. Success returns a simple JSON response with status: healthy. Database failure raises an HTTPException with status code 503 (Service Unavailable), the standard code monitoring tools and proxies recognize as “I’m down; don’t send traffic.”

More complex applications might separate health checks into different endpoints—one checking if a process is alive, another checking if it can serve requests. For this blog application, one endpoint checking the database suffices.

Start the server and verify the endpoint works by visiting /health. The response shows status: healthy. The endpoint also appears in the API documentation at /docs.

Pushing Code to GitHub

The code must be accessible on the server. Options include placing it in a repository and pulling on the server, or using SCP to copy files over SSH. GitHub will be used here with a public repository (since the code will be linked in the video description anyway—private repos are also free on GitHub if needed).

Create a new GitHub repository named fastapi-tutorial-18-deployment-vps. Leave default options selected (public repo, no README). GitHub displays quick setup instructions for creating a new repo from the command line or pushing an existing repo.

Since this project has been tracked with Git, use the existing repository commands:

git remote add origin https://github.com/username/fastapi-tutorial-18-deployment-vps.git
git branch -M main
git push -u origin main

Critical reminder: If using Git, ensure a .gitignore file exists and includes the .env file. Sensitive credentials must never be pushed to GitHub.

The existing code is now on GitHub, but the new health check endpoint hasn’t been committed. Commit and push those changes:

git add -A
git commit -m "Add health check endpoint"
git push

Verify on GitHub that the health check appears in main.py.

Creating the VPS

Navigate to the VPS provider (Linode in this case) and create a new server. The setup process:

  1. Image: Choose Ubuntu 24.04 LTS (Long-Term Support receives security updates for extended periods—typically preferred for servers)
  2. Region: Select the location closest to expected users (Dallas, Texas US Central in this example)
  3. Plan: Choose the shared CPU plan with 1GB RAM at $5/month—sufficient for small to medium traffic applications and upgradable later if more resources are needed
  4. Label: Name the server fastapi-blog
  5. Root Password: Use a strong password (record it securely—fastapi-blog-1! in this example)
  6. Firewall: Select “no firewall” for now (it will be configured manually)

Create the Linode. The server provisions and receives a public IP address. Once provisioning completes and the server shows as running, the IP address is available.

Initial Server Setup

Open a terminal and SSH into the server as root:

ssh root@96.126.113.103

(Replace with your server’s IP address)

Accept the fingerprint when prompted (this only happens on first connection). Enter the root password.

This is the first and last time logging in as root.

Updating the System

Start by updating the system to ensure all security patches and bug fixes are current:

apt update && apt upgrade -y

During updates, prompts may appear about modified config files (like SSH configuration). For fresh installs, select the package maintainer’s version.

Creating a Non-Root User

Root has unlimited system power. If root is compromised, attackers have full control. Using a regular user with sudo privileges limits damage from mistakes or security incidents—a best practice for any server.

Create a new user:

adduser coreyms

Enter a strong password when prompted. Press Enter to accept defaults for additional details.

Grant sudo privileges to the new user:

usermod -aG sudo coreyms

This command modifies the user (usermod), appending (-aG) them to the sudo group.

Verify the user was added to the sudo group:

groups coreyms

The output should include sudo, confirming the user can run administrative commands.

SSH Key Setup

Password authentication for server login is not ideal. SSH keys are significantly more secure—no password to brute force, keys are much longer than passwords, and private keys remain on the local machine.

Generating an SSH Key (Local Machine)

Open a new terminal on the local machine (not the server). For Windows users, use Windows Subsystem for Linux (WSL) or Git Bash for Linux-like commands.

Generate an SSH key:

ssh-keygen -t ed25519 -C "coreyms-imac-2026-04-14"

Ed25519 is the modern recommended algorithm—faster and more secure than older RSA keys. The -C flag adds a comment for identification. Use a descriptive format: name-device-date helps remember what each key is for and when it was created.

Specify where to save the key. Use a descriptive filename:

/Users/username/.ssh/id_ed25519_fastapi_blog_imac

This pattern (algorithm_servername_device) makes keys easily identifiable.

Enter a strong passphrase when prompted. This protects the key if the machine is ever compromised.

The command creates two files: the private key (id_ed25519_fastapi_blog_imac) and the public key (id_ed25519_fastapi_blog_imac.pub).

Copying the Public Key to the Server

Use ssh-copy-id to copy the public key:

ssh-copy-id -i ~/.ssh/id_ed25519_fastapi_blog_imac.pub coreyms@96.126.113.103

Enter the password for the user created on the server. The key copies to the server.

Adding the Key to SSH Agent

The SSH agent caches the key, eliminating the need to specify the -i flag with the key path for every SSH connection. It also caches the passphrase, preventing repeated entry.

ssh-add ~/.ssh/id_ed25519_fastapi_blog_imac

Enter the passphrase. The key is now added to the agent.

Testing Key-Based Login

Critical step: Test key-based login before disabling password authentication. If key-based authentication doesn’t work, disabling passwords could lock you out.

ssh coreyms@96.126.113.103

The connection should succeed without requesting a password, confirming SSH key functionality.

Setting Correct Permissions

Verify and set correct permissions on the SSH directory on the server. While permissions should already be correct, manually setting them ensures proper configuration:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/*

700 means read/write/execute for the owner only on the directory. 600 means read/write for the owner only on all files inside. These are the correct permissions for SSH.

Hardening SSH Configuration

Disable root login and password authentication entirely. The only way into the server should be with an SSH key.

Edit the SSH configuration:

sudo nano /etc/ssh/sshd_config

Find and modify these settings:

PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
PermitEmptyPasswords no
KbdInteractiveAuthentication no

PermitRootLogin no disables root login entirely. PubkeyAuthentication yes ensures key-based authentication is enabled. PasswordAuthentication no disables password login—only SSH keys work. KbdInteractiveAuthentication no disables challenge-response authentication, another password-based method.

Save changes (Control-X, Y, Enter in nano).

Restart the SSH service:

sudo systemctl restart ssh

Critical: Before closing the current session, test in a new terminal that SSH key login still works. If something went wrong with the configuration, you’d be locked out. Keeping the current session open allows fixing issues before lockout.

Open a new terminal and test:

ssh coreyms@96.126.113.103

Login should succeed without a password. The only way to access the server now is with SSH keys. No one can brute force passwords because password authentication is completely disabled.

Additional Security Hardening

Setting the Time Zone

Set the server to UTC to keep logs consistent and avoid daylight saving time confusion:

Check current timezone:

timedatectl

Set to UTC if not already:

sudo timedatectl set-timezone UTC

Verify:

timedatectl

Setting the Hostname

A clear hostname makes logs and command prompts easier to understand:

sudo hostnamectl set-hostname fastapi-blog

Verify:

hostname

Add the hostname to the hosts file:

sudo nano /etc/hosts

Under localhost, add (use your server’s IP):

96.126.113.103 fastapi-blog

Save and exit.

Configuring the Firewall

Use UFW (Uncomplicated Firewall), typically pre-installed on Ubuntu:

sudo apt install ufw -y

Set default policies—deny all incoming traffic, allow all outgoing traffic, then open only specific needed ports:

sudo ufw default deny incoming
sudo ufw default allow outgoing

Critical: Allow SSH before enabling the firewall. Enabling without allowing SSH locks you out:

sudo ufw allow ssh

Allow HTTP and HTTPS for the web server:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

Enable the firewall:

sudo ufw enable

Confirm when prompted.

Verify rules:

sudo ufw status verbose

Output should show: default deny incoming, default allow outgoing, and only SSH (22), HTTP (80), and HTTPS (443) allowed. Everything else is blocked.

Setting Up Fail2Ban

Fail2Ban monitors log files for suspicious activity and bans IP addresses with too many failed login attempts, protecting against brute force attacks. Even with password authentication disabled, this is good practice.

Install Fail2Ban:

sudo apt install fail2ban -y

Create local config files (never edit .conf files directly—they get overwritten during updates):

sudo cp /etc/fail2ban/fail2ban.conf /etc/fail2ban/fail2ban.local
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

Edit the jail configuration:

sudo nano /etc/fail2ban/jail.local

Find the [DEFAULT] section and set:

banaction = ufw

This integrates with the firewall.

Find the [sshd] section and add:

enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
backend = auto
maxretry = 3
findtime = 10m
bantime = 1h

Translation: If someone fails to log in 3 times within 10 minutes, ban them for 1 hour.

Save and exit.

Start and enable Fail2Ban:

sudo systemctl start fail2ban
sudo systemctl enable fail2ban

Check status:

sudo systemctl status fail2ban

View banned IPs:

sudo fail2ban-client status sshd

After some time, this will start filling with banned IPs. Fail2Ban reads recent logs on startup and bans past offenders. Even after only 30 minutes online, automated bots are already attempting break-ins. Security hardening is not optional—it’s necessary. Automated bots constantly scan the internet for vulnerable servers. Without protections, compromise is inevitable.

Enabling Automatic Security Updates

Ubuntu’s unattended-upgrades tool keeps security patches current automatically. It applies only security updates, not all packages, making it safe to leave enabled:

sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades

Select “Yes” when prompted.

Verify configuration:

cat /etc/apt/apt.conf.d/20auto-upgrades

The output confirms automatic checking and installation of security updates. Optional: Configure automatic reboots for kernel updates by editing the unattended-upgrades config and setting Automatic-Reboot to true with a specific time. This tutorial skips that for hands-on control, but fully hands-off servers should enable it.

The server now has: a non-root user, SSH key authentication, firewall configured, Fail2Ban protecting SSH, and automatic security updates. Everything covered so far applies to almost any server used for websites, regardless of technology stack (Python, Node, Go, etc.). This is a solid baseline server setup.

Installing and Configuring Nginx

Nginx is a reverse proxy that sits in front of the application. It handles SSL/HTTPS, serves static files efficiently, and provides load balancing and caching capabilities. It’s the industry standard for this role.

Install Nginx:

sudo apt update
sudo apt install nginx -y

Start and enable Nginx:

sudo systemctl start nginx
sudo systemctl enable nginx

Check status:

sudo systemctl status nginx

Test by visiting the server’s IP address in a browser. The Nginx welcome page should appear, confirming Nginx is running.

DNS Configuration

Instead of using an IP address, configure a domain name. The domain myawesomeapp.com is used here, currently pointing elsewhere. It will be redirected to the new server—a common scenario when moving from static hosting to a dynamic application.

Navigate to the domain registrar (Namecheap in this case). Find DNS settings (might be called “Advanced DNS,” “DNS Management,” or “DNS Records” depending on the registrar). Delete existing A records and any CNAME records.

Add new A records:

  1. Type: A Record
    Host: @ (for the non-www version: myawesomeapp.com)
    Value: 96.126.113.103 (your server IP)
  2. Type: A Record
    Host: www (for the www version: www.myawesomeapp.com)
    Value: 96.126.113.103 (your server IP)

DNS propagation can take up to 48 hours but is usually much faster (5-30 minutes).

Test if propagation is complete:

dig myawesomeapp.com +short
dig www.myawesomeapp.com +short

Both should return the server’s IP address. If they don’t, wait and try again until propagation completes.

Configuring Nginx for the Domain

Create a site configuration file:

sudo nano /etc/nginx/sites-available/myawesomeapp.com

Add initial configuration:

# Redirect www to non-www
server {
    server_name www.myawesomeapp.com;
    return 301 $scheme://myawesomeapp.com$request_uri;
}
 
# Main server block
server {
    server_name myawesomeapp.com;
    
    root /var/www/html;
    index index.html;
    
    location / {
        try_files $uri $uri/ =404;
    }
}

The first block redirects www to non-www (personal preference—reverse configuration is equally valid). The second block is the main server handling actual requests. For now, it serves the default Nginx page. This will be updated later to point to the FastAPI application.

Save and exit.

Enable the site by creating a symlink:

sudo ln -s /etc/nginx/sites-available/myawesomeapp.com /etc/nginx/sites-enabled/

Remove the default site:

sudo rm /etc/nginx/sites-enabled/default

Test the configuration for syntax errors:

sudo nginx -t

Output should show “syntax is ok” and “test is successful.”

Reload Nginx:

sudo systemctl reload nginx

Setting Up HTTPS with Let’s Encrypt

HTTPS encrypts traffic between users and the server. It’s required for the modern web—it’s an SEO ranking factor, and browsers display warnings on plain HTTP sites. This step is essential.

Certbot will be used to obtain free SSL certificates from Let’s Encrypt.

Update and install prerequisites:

sudo apt update
sudo apt install snapd -y
sudo snap install core
sudo snap refresh core

Remove any old Certbot installation:

sudo apt remove certbot

Install Certbot via Snap:

sudo snap install --classic certbot

Create a symlink for easier access:

sudo ln -s /snap/bin/certbot /usr/bin/certbot

Before running Certbot, ensure DNS has propagated. Certbot needs to verify the domain points to this server. If DNS hasn’t propagated, verification fails. Test again:

dig myawesomeapp.com +short

If it returns the server IP, proceed.

Obtain the certificate:

sudo certbot --nginx -d myawesomeapp.com -d www.myawesomeapp.com

Follow the prompts:

  • Enter an email address
  • Agree to terms of service (required)
  • Share email address (optional)

Certbot automatically configures Nginx with SSL settings, modifying the config file to add SSL configuration.

Test automatic renewal (certificates expire after 90 days, but Certbot sets up automatic renewal):

sudo certbot renew --dry-run

Output should show successful simulation of certificate renewal.

Verify the renewal timer is active:

systemctl list-timers | grep certbot

The timer should appear, confirming automatic renewal is configured.

Visit the site in a browser:

https://myawesomeapp.com

The connection should be secure with a valid certificate. The www version should redirect to non-www. Clicking the padlock icon shows “Connection is secure.”

If a 403 Forbidden error appears, this is expected—Nginx is pointing to a default file that doesn’t exist yet. The important verification is that HTTPS works and the certificate is valid.

Installing Python and Dependencies

Install Python and required packages:

sudo apt update
sudo apt install python3 python3-pip python3-venv git -y

Verify Python version:

python3 --version

Ubuntu 24.04 should have Python 3.12 or higher.

Install UV (the fast Python package manager used throughout the series):

curl -LsSf https://astral.sh/uv/install.sh | sh

Reload the shell to pick up the new path:

source ~/.bashrc

Verify UV is installed:

uv --version

Installing and Configuring PostgreSQL

Install PostgreSQL:

sudo apt update
sudo apt install postgresql postgresql-contrib -y

Start and enable PostgreSQL:

sudo systemctl start postgresql
sudo systemctl enable postgresql

Check status:

sudo systemctl status postgresql

Create database user and database:

sudo -u postgres psql

Inside the PostgreSQL prompt:

CREATE USER blog_user WITH PASSWORD 'blog_pass';
CREATE DATABASE blog OWNER blog_user;
GRANT ALL PRIVILEGES ON DATABASE blog TO blog_user;
\q

Use a strong database password for production. A simple one is used here for tutorial purposes.

Test the connection:

psql -U blog_user -d blog -h localhost

Enter the password (blog_pass). The connection should succeed. Exit with \q.

Production credentials will be set in the .env file shortly.

Deploying Application Code

Create the application directory:

sudo mkdir -p /var/www/fastapi-blog

Change ownership to the current user:

sudo chown coreyms:coreyms /var/www/fastapi-blog

Navigate to the directory:

cd /var/www/fastapi-blog

Clone the repository:

git clone https://github.com/username/fastapi-tutorial-18-deployment-vps.git .

(The period at the end clones into the current directory)

Alternative: Use SCP to copy files from the local machine. Note that scp with the * wildcard won’t copy dot files—actually fine since the .env file shouldn’t be copied from development (it should be created fresh on the server with production values), and the virtual environment at .venv will be created by uv sync.

Setting Secure File Permissions

When copying files with SCP or cloning with Git, permissions can be inconsistent. Lock everything down, then open only what needs to be open:

find . -type f -exec chmod 600 {} \;
find . -type d -exec chmod 700 {} \;

The first command gives all files read/write for owner only. The second gives all directories read/write/execute for owner only. This is a deny-by-default approach.

Open permissions for what Nginx needs to serve static files (Nginx runs as the www-data user and needs to traverse directories and read static files):

chmod 755 .
chmod 755 static

Installing Python Dependencies

UV sync creates the virtual environment and installs everything from pyproject.toml and the lock file:

uv sync

Creating the Production .env File

Generate a secret key:

python3 -c "import secrets; print(secrets.token_hex(32))"

Copy the output (a long random string).

Create the .env file:

nano .env

Add environment variables (replace placeholders with your actual values):

DATABASE_URL=postgresql+asyncpg://blog_user:blog_pass@localhost/blog
SECRET_KEY=<paste_your_generated_key_here>
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30

S3_BUCKET_NAME=your-bucket-name
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key

MAX_UPLOAD_SIZE_BYTES=5242880
POSTS_PER_PAGE=10
RESET_TOKEN_EXPIRE_MINUTES=60

MAIL_SERVER=live.smtp.mailtrap.io
MAIL_PORT=587
MAIL_USERNAME=your-mailtrap-username
MAIL_PASSWORD=your-mailtrap-password
MAIL_FROM=noreply@myawesomeapp.com
MAIL_USE_TLS=True

FRONTEND_URL=https://myawesomeapp.com

Key points:

  • Use your actual database credentials
  • Paste the generated secret key
  • Mail port changed from 2525 to 587. 2525 was for Mailtrap’s sandbox. 587 is the standard SMTP submission port used by virtually every production email provider (SendGrid, AWS SES, Mailgun, etc.). If switching to a production email service, update mail server, username, and password—port 587 will already be correct.
  • Use your actual S3 credentials
  • Set FRONTEND_URL to your actual domain

Save and exit.

Secure the .env file:

chmod 600 .env

This restricts it to read/write for owner only.

Running Database Migrations

Run migrations to create database tables:

uv run alembic upgrade head

Migrations run, creating database tables.

Manual Test

Temporarily open port 8000 on the firewall to test the application directly:

sudo ufw allow 8000/tcp

Start the app:

uv run fastapi run --host 0.0.0.0 --port 8000

The --host 0.0.0.0 makes it accessible from outside the server, not just localhost.

Visit in a browser:

http://96.126.113.103:8000

The application should load. The database is fresh, so no posts exist yet.

Test the health endpoint:

http://96.126.113.103:8000/health

Response should show status: healthy, confirming database connectivity.

Stop the server (Control-C) and remove the temporary firewall rule:

sudo ufw delete allow 8000/tcp

All traffic will now go through Nginx.

Setting Up systemd to Manage the Application

Systemd is Linux’s system service manager. It handles starting, stopping, and restarting services. It starts the app on boot, and if the app crashes, systemd automatically restarts it.

Check CPU core count (affects worker settings):

nproc

The cheapest Linode has 1 CPU core. For async workers (used with FastAPI), use one worker per core. This differs from the old formula (cores × 2 + 1) for synchronous workers. Since the app is fully async, one worker per core is correct.

Create the systemd service file:

sudo nano /etc/systemd/system/fastapi-blog.service

Add configuration:

[Unit]
Description=FastAPI Blog Application
After=network.target postgresql.service
Wants=postgresql.service
 
[Service]
User=coreyms
Group=www-data
WorkingDirectory=/var/www/fastapi-blog
Environment="PATH=/var/www/fastapi-blog/.venv/bin:/usr/local/bin:/usr/bin:/bin"
EnvironmentFile=/var/www/fastapi-blog/.env
ExecStart=/var/www/fastapi-blog/.venv/bin/fastapi run --workers 1 --host 127.0.0.1 --port 8000 --proxy-headers
Restart=always
RestartSec=5
 
[Install]
WantedBy=multi-user.target

Configuration breakdown: [Unit] section:

  • Description: Service description
  • After: Service starts after network and PostgreSQL are available
  • Wants: Prefer PostgreSQL running, but service won’t fail if it’s not

[Service] section:

  • User=coreyms: Run as the coreyms user
  • Group=www-data: Run with www-data group
  • WorkingDirectory: Application directory
  • Environment="PATH=...": Include virtual environment’s bin directory for finding installed packages
  • EnvironmentFile: Points to .env file so environment variables load
  • ExecStart: Actual command to run the application
    • Full path to the fastapi command inside the virtual environment
    • --workers 1: Set to CPU core count (1 in this case)
    • --host 127.0.0.1: Nginx will proxy traffic on localhost
    • --port 8000: Application port
    • --proxy-headers: Tell uvicorn to trust X-Forwarded-For headers from Nginx (necessary for the application to see correct client IP and protocol)
  • Restart=always: If the process dies, systemd restarts it
  • RestartSec=5: Five-second delay before restarting

[Install] section:

  • WantedBy=multi-user.target: Service starts when system boots to normal multi-user run level

Save and exit.

Reload systemd to pick up the new service:

sudo systemctl daemon-reload

Start and enable the service:

sudo systemctl start fastapi-blog
sudo systemctl enable fastapi-blog

Check status:

sudo systemctl status fastapi-blog

Status should show “enabled” and “running.”

Updating Nginx Configuration as Reverse Proxy

Update the Nginx configuration to act as a reverse proxy for the FastAPI application:

sudo nano /etc/nginx/sites-available/myawesomeapp.com

Clear existing content and replace with full production configuration:

# Redirect www to non-www
server {
    server_name www.myawesomeapp.com;
    return 301 $scheme://myawesomeapp.com$request_uri;
}
 
# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name myawesomeapp.com;
    return 301 https://$host$request_uri;
}
 
# Main HTTPS server
server {
    listen 443 ssl http2;
    server_name myawesomeapp.com;
 
    # SSL certificates (managed by Certbot)
    ssl_certificate /etc/letsencrypt/live/myawesomeapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myawesomeapp.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
 
    # 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;
 
    # Client upload size limit (matches application setting)
    client_max_body_size 5M;
 
    # Timeouts
    proxy_connect_timeout 60s;
    proxy_send_timeout 60s;
    proxy_read_timeout 60s;
 
    # Static files
    location /static/ {
        alias /var/www/fastapi-blog/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
 
    # Reverse proxy to FastAPI
    location / {
        proxy_pass http://127.0.0.1:8000;
        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;
    }
}

Configuration highlights:

Three server blocks:

  1. Redirects www to non-www
  2. Redirects HTTP to HTTPS
  3. Main HTTPS server block

Static files location: Tells Nginx to serve files from the static directory directly without passing them through FastAPI. This is much more efficient—Nginx is optimized for serving static content.

Main location block: The reverse proxy. Nginx takes incoming requests and forwards them to the FastAPI application running on port 8000. Headers like Host, X-Real-IP, and X-Forwarded-For pass through so the application knows about the original request.

client_max_body_size 5M: Matches the application’s upload limit for profile pictures.

Nginx configurations can be extensive. This includes standard boilerplate for SSL certificates, security headers, and timeouts found in most Nginx templates.

Save and exit.

Test the configuration:

sudo nginx -t

Output should show “syntax is ok” and “test is successful.”

Reload Nginx:

sudo systemctl reload nginx

The FastAPI application should now be live at the domain.

Testing the Deployment

Visit the domain in a browser:

https://myawesomeapp.com

The application loads with a secure connection.

Test the health check:

https://myawesomeapp.com/health

Response shows status: healthy.

Check API documentation:

https://myawesomeapp.com/docs

Documentation loads successfully.

Create a user through the register page. Create a post. Upload a profile picture to verify S3 integration works. The profile picture should load with an AWS S3 URL.

If using an email service or Mailtrap, test the password reset flow to verify email functionality works in production.

Everything is now deployed and functional.

Maintenance Commands

Check application status:

sudo systemctl status fastapi-blog

Restart after code changes:

sudo systemctl restart fastapi-blog

View application logs:

sudo journalctl -u fastapi-blog -f

(Some requests visible in logs may be bots scanning for vulnerabilities)

Updating the Application

When code changes are needed:

  1. Push changes to Git repository
  2. On the server, navigate to project directory:
    cd /var/www/fastapi-blog
  3. Pull code changes:
    git pull
  4. Sync dependencies:
    uv sync
  5. Run migrations if needed:
    uv run alembic upgrade head
  6. Restart the service:
    sudo systemctl restart fastapi-blog

Summary and Next Steps

The server is now fully deployed with:

  • Key-based SSH access (locked down)
  • Firewall and brute force protection (UFW + Fail2Ban)
  • HTTPS with automatic certificate renewal (Let’s Encrypt)
  • Nginx handling traffic as a reverse proxy
  • FastAPI application managed by systemd (starts on boot, restarts on crashes)

This process may seem extensive for deploying a website. However, with the process established, automation tools like Ansible can script everything demonstrated here. Ansible lets you define server setup in a configuration file and run it against any number of servers. Everything covered in this tutorial can be converted into an Ansible Playbook that repeats the process automatically. A separate Ansible video will cover this outside the series.

This tutorial demonstrated manual VPS deployment—full control, fundamental understanding, and fixed monthly cost. Covering this approach first builds understanding of what Docker and managed platforms abstract away. The next tutorial deploys the same application using containerized cloud deployment with Google Cloud Run—Docker containers, managed infrastructure, and automatic scaling to zero when there’s no traffic.

Both approaches are valid production choices. The decision depends on project needs. If a VPS suits your requirements, the series is essentially complete—you have a fully functional FastAPI app deployed publicly with a domain name. To compare deployment strategies, continue to the Docker deployment tutorial to see how that approach differs.