Running your own Python application on a VPS can be a great way to learn how servers, containers, databases, domains, and HTTPS work together.
It also gives you more control over your application. Instead of depending entirely on a managed hosting platform, you can deploy your application on your own virtual server and decide how the environment is configured.
In this tutorial, we'll deploy a small FastAPI application on a Linux VPS using:
FastAPI for the web application
PostgreSQL for the database
Docker for containerization
Docker Compose for managing services
Caddy as the reverse proxy and HTTPS layer
A container registry for storing application images
The goal isn't to build a huge production infrastructure. Instead, we'll create a setup that is simple enough for beginners to understand and useful enough for small projects, APIs, SaaS prototypes, and side projects.
Who is this guide for?
This guide is useful if you:
want to learn how VPS deployment works
are building a small Python API
want to understand Docker in a real project
want more control over your hosting environment
are experimenting with self-hosting
want to deploy a FastAPI project without using a fully managed platform
It may not be the best approach if you need:
automatic scaling from day one
zero server maintenance
enterprise-grade infrastructure
a managed database
a large distributed system
For a small application, however, a single VPS can be surprisingly capable.
What we'll build
At the end of this guide, the request flow will look roughly like this:
User
|
| HTTPS
v
Caddy
|
| HTTP
v
FastAPI
|
| PostgreSQL connection
v
PostgreSQL
Everything except the VPS itself will run through Docker containers.
This is one of the useful things about Docker Compose: instead of manually installing every dependency on the server, you can describe the services your application needs in a Compose file and manage them together. Docker describes Compose as a way to define and run multi-container applications across development, testing, staging, and production environments.
Prerequisites
Before starting, you should have:
A working FastAPI application
A Linux VPS
A domain name
Basic knowledge of the terminal
SSH access to your VPS
Docker installed locally
A container registry account
You don't need to be a DevOps expert.
If you understand basic Linux commands such as cd, ls, mkdir, and ssh, you can follow along.
1. Prepare the FastAPI application
Let's assume our project has a structure similar to this:
my-fastapi-app/
├── app/
│ ├── init.py
│ └── main.py
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── .env
A very simple FastAPI application could look like this:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def home():
return {"message": "Hello from FastAPI!"}
And our requirements.txt:
fastapi
uvicorn[standard]
You can add other packages later as your project grows.
For example:
fastapi
uvicorn[standard]
sqlalchemy
psycopg[binary]
python-dotenv
The important thing is that the application works locally before we introduce the VPS.
2. Create a Dockerfile
Now we need to package the application.
Create a file named:
Dockerfile
Add:
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
What is happening here?
The Dockerfile starts with a lightweight Python image.
Then:
/app becomes the working directory.
Python environment variables are configured.
Dependencies are installed.
Application files are copied into the image.
Port 8000 is exposed.
Uvicorn starts the FastAPI application.
The important idea is that the server doesn't need to know how you originally built your Python environment.
The container contains what the application needs.
3. Add Docker Compose
Our FastAPI application needs a database, so let's create:
docker-compose.yml
A simple setup:
services:
api:
build: .
ports:
- "8000:8000"
env_file:
- .env
depends_on:
- db
db:
image: postgres:17
restart: unless-stopped
env_file:
- .env
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Now we have two services:
api
This is our FastAPI application.
db
This is PostgreSQL.
The named volume is important because database data shouldn't disappear simply because the PostgreSQL container is recreated.
4. Configure environment variables
Create a .env file:
POSTGRES_DB=myapp
POSTGRES_USER=myappuser
POSTGRES_PASSWORD=change-this-password
DATABASE_URL=postgresql://myappuser:change-this-password@db:5432/myapp
Don't commit real passwords or secret keys to a public Git repository.
Docker's documentation also recommends being careful with sensitive information stored in environment variables and considering secrets for more serious deployments.
For a production application, you should also generate a strong application secret rather than using an example value.
- Test everything locally
Before touching your VPS, test the setup on your own computer.
Run:
docker compose up --build
Docker will:
Build the FastAPI image.
Start the PostgreSQL container.
Start the API container.
Create the Docker network connecting them.
Then open:
You should see:
{
"message": "Hello from FastAPI!"
}
You can also check FastAPI's automatically generated documentation:
This is one of my favorite parts of FastAPI for learning because you immediately get an interactive API interface.
If the application doesn't work locally, don't move to the VPS yet.
Fix the local setup first.
6. Build the Docker image
Once the application works, build the production image:
docker build -t my-fastapi-app .
You can check that the image exists with:
docker images
At this stage, you have a portable application image.
That image can be stored in a container registry and later pulled by your VPS.
7. Push the image to a container registry
You have several choices for storing Docker images.
For this example, we'll use GitHub Container Registry.
GitHub's Container Registry supports Docker and OCI images and allows images to be stored under personal accounts or organizations.
First authenticate:
echo $CR_PAT | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
Then tag your image:
docker tag my-fastapi-app \
ghcr.io/YOUR_GITHUB_USERNAME/my-fastapi-app:latest
Push it:
docker push \
ghcr.io/YOUR_GITHUB_USERNAME/my-fastapi-app:latest
For real projects, I recommend using meaningful version tags instead of relying only on latest.
For example:
v1.0.0
v1.1.0
v1.2.0
Or use a Git commit SHA.
That makes rollbacks much easier.
GitHub also supports pulling images by digest when you need an exact immutable image version.
8. Choose your VPS
For a small FastAPI project, you don't need a massive server.
A basic VPS can be enough for:
a small API
a PostgreSQL database
a few background processes
development projects
personal applications
small SaaS prototypes
The right VPS depends on your workload.
Consider:
RAM
Memory becomes important when you run several containers.
CPU
A small API usually doesn't need many CPU cores, but CPU-heavy applications may need more.
Storage
Remember that your database, logs, Docker images, and uploaded files all consume storage.
Location
Choose a data center reasonably close to your users when latency matters.
Backups
A VPS is not automatically a backup system.
This is an important distinction.
If your database exists only on one server and that server fails, you could lose your data.
9. Connect to the VPS
Once your server is ready, connect through SSH:
ssh root@YOUR_SERVER_IP
If your provider recommends connecting through a non-root user, use that account instead.
For better security, SSH keys are generally preferable to password-based login.
10. Update the server
On Ubuntu or another Debian-based system:
sudo apt update
sudo apt upgrade -y
Keeping the server updated is basic but important.
A server exposed to the public internet should not be treated like a temporary development machine.
11. Configure the firewall
For this setup, the public services generally need:
22 SSH
80 HTTP
443 HTTPS
Your application itself doesn't need to expose port 8000 publicly when Caddy is sitting in front of it.
That gives us:
Internet
|
+---- 80/443 ----> Caddy
|
+----> FastAPI :8000
Keeping internal application ports away from the public internet reduces unnecessary exposure.
12. Install Docker
Install Docker using the official instructions for your Linux distribution.
After installation, check:
docker --version
and:
docker compose version
If both commands work, you're ready for the deployment.
13. Prepare the production Compose file
Our local Compose configuration builds the application on the machine.
For production, it's often better to pull a previously built image.
Docker's production guidance recommends separating production-specific configuration where appropriate, including things such as ports, environment variables, restart policies, and additional services.
For example:
services:
api:
image: ghcr.io/YOUR_GITHUB_USERNAME/my-fastapi-app:latest
restart: unless-stopped
env_file:
- .env
expose:
- "8000"
depends_on:
- db
db:
image: postgres:17
restart: unless-stopped
env_file:
- .env
volumes:
- postgres_data:/var/lib/postgresql/data
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- api
volumes:
postgres_data:
caddy_data:
caddy_config:
Notice something important:
We aren't publishing:
8000:8000
for the API.
Instead, Caddy can communicate with the API internally.
14. Configure Caddy
Create a file named:
Caddyfile
Add:
api.example.com {
reverse_proxy api:8000
}
Replace api.example.com with your actual domain.
Caddy acts as a reverse proxy.
The request flow becomes:
Browser
|
| HTTPS
v
Caddy
|
| HTTP inside Docker network
v
FastAPI
|
v
PostgreSQL
Caddy's reverse proxy can forward requests to backend services, and its HTTPS setup can automatically obtain publicly trusted certificates when the domain points correctly to the server and ports 80/443 are reachable.
This is one reason Caddy is convenient for small self-hosted projects.
15. Point your domain to the VPS
Go to your domain's DNS settings.
Create an A record:
Type: A
Name: @
Value: YOUR_SERVER_IP
If you're using a subdomain:
Type: A
Name: api
Value: YOUR_SERVER_IP
DNS changes can take some time to become visible depending on your DNS configuration.
Before starting Caddy, make sure the domain actually points to the correct server.
16. Log in to the container registry
If your image is private, your VPS needs permission to pull it.
For GitHub Container Registry:
echo $CR_PAT | docker login ghcr.io \
-u YOUR_GITHUB_USERNAME \
--password-stdin
GitHub's documentation notes that private packages require authentication and that a token needs appropriate package permissions.
Don't paste credentials directly into scripts that may become public.
17. Start the application
Now run:
docker compose up -d
Check the containers:
docker compose ps
You should see something similar to:
api
db
caddy
If something isn't working, inspect the logs:
docker compose logs api
For PostgreSQL:
docker compose logs db
For Caddy:
docker compose logs caddy
Logs are usually the first place I look when a containerized deployment doesn't behave as expected.
- Test your live API
Open:
You should receive the response from your FastAPI application.
Then try:
If the FastAPI documentation appears, congratulations.
Your API is now running on your VPS behind HTTPS.
19. Deploying future updates
One of the benefits of using container images is that updating your application becomes straightforward.
After changing your code locally:
docker build -t my-fastapi-app .
Tag it:
docker tag my-fastapi-app \
ghcr.io/YOUR_GITHUB_USERNAME/my-fastapi-app:v1.1.0
Push it:
docker push \
ghcr.io/YOUR_GITHUB_USERNAME/my-fastapi-app:v1.1.0
Then update the image reference on your VPS.
For example:
image: ghcr.io/YOUR_GITHUB_USERNAME/my-fastapi-app:v1.1.0
Then:
docker compose pull api
docker compose up -d api
Docker documents this general pattern of rebuilding/pulling an updated image and recreating the affected service rather than unnecessarily restarting the entire stack.
20. Don't forget database migrations
This is an area where beginners often get surprised.
Updating application code is not always enough.
Suppose your application changes its database structure:
users
↓
users + subscription_plan
Your new application code may expect a column that doesn't exist yet.
For a real FastAPI application using SQLAlchemy and Alembic, you would normally run migrations during deployment.
For example:
docker compose exec api alembic upgrade head
The exact command depends on how your application handles database changes.
The important lesson is:
Application deployment and database migration are related, but they are not the same operation.
Plan both.
21. Protect your production configuration
Never put credentials like these directly into your public repository:
POSTGRES_PASSWORD
DATABASE_URL
SECRET_KEY
API_KEYS
Use environment variables or a proper secrets system.
Also make sure .env is included in .gitignore:
.env
Django's official deployment checklist similarly emphasizes protecting secrets and production database credentials.
The exact framework differs, but the principle is the same:
If a secret doesn't need to be public, don't commit it to source control.
22. Add backups before you need them
A Docker volume isn't the same thing as a backup.
Our PostgreSQL data is stored in:
volumes:
- postgres_data:/var/lib/postgresql/data
That's useful because the data survives container recreation.
But imagine the entire VPS disappears.
The volume disappears with it.
For anything important, create regular database backups and store them somewhere separate from the server.
A simple PostgreSQL backup might eventually look like:
pg_dump DATABASE_NAME > backup.sql
The exact backup strategy depends on your database setup, but the principle is simple:
Keep at least one copy somewhere outside the machine running your application.
23. Add monitoring as your project grows
You don't need a huge monitoring stack on day one.
Start with basic checks:
docker compose ps
and:
docker compose logs
As the application becomes more important, consider adding:
uptime monitoring
application error tracking
centralized logs
CPU and memory monitoring
database monitoring
alerts
This is where a small VPS deployment can gradually evolve into a more serious infrastructure setup.
24. Automate deployments with CI/CD
Manually running:
docker build
docker push
docker compose pull
docker compose up
every time you make a change becomes annoying quickly.
A better workflow is:
Git push
↓
GitHub Actions
↓
Build Docker image
↓
Run tests
↓
Push image
↓
VPS pulls image
↓
Restart API
GitHub's Container Registry can integrate with GitHub Actions, and GitHub recommends using GITHUB_TOKEN for workflows publishing packages associated with the repository.
You don't have to automate everything immediately.
Get the manual deployment working first.
Then automate the boring parts.
Common problems you may encounter
Container starts and immediately stops
Check:
docker compose logs api
Usually the error will tell you whether it's a missing dependency, invalid environment variable, incorrect module path, or application error.
Caddy returns a 502 error
A 502 generally means the proxy cannot successfully reach the backend.
Check:
docker compose ps
Then:
docker compose logs caddy
and:
docker compose logs api
Also verify that your Caddy configuration points to:
api:8000
rather than:
localhost:8000
Inside the Docker network, api is the service name.
HTTPS isn't working
Check three things:
Your DNS record points to the VPS.
Port 80 is reachable.
Port 443 is reachable.
Caddy's automatic HTTPS relies on the domain being correctly configured and publicly reachable.
The database disappears after a restart
Check your Compose file.
You should have a persistent volume:
volumes:
- postgres_data:/var/lib/postgresql/data
Without persistent storage, recreating a database container can result in data loss.
And remember: persistent storage is still not a backup.
What we built
Let's recap the architecture:
Internet
|
| HTTPS
v
+---------+
| Caddy |
+---------+
|
| HTTP
v
+---------+
| FastAPI |
+---------+
|
|
v
+-----------+
| PostgreSQL|
+-----------+
All services run with Docker Compose
Our final stack contains:
FastAPI — application framework
Uvicorn — application server
PostgreSQL — database
Docker — container runtime
Docker Compose — service management
Caddy — reverse proxy and HTTPS
VPS — server infrastructure
Container Registry — application image storage
Final thoughts
Deploying a FastAPI application manually on a VPS can feel complicated the first time.
You have to understand several things at once:
Linux
SSH
Docker
networking
DNS
databases
HTTPS
containers
environment variables
But that's also why it's such a useful learning experience.
Once you understand how a request travels from:
Browser → DNS → VPS → Caddy → Docker → FastAPI → PostgreSQL
many modern deployment systems start making much more sense.
You don't need Kubernetes for every project.
You don't need a complicated cloud architecture for every API.
For a small application, a single VPS with Docker Compose can be a practical starting point. As your traffic and requirements grow, you can add managed databases, load balancing, monitoring, CI/CD, caching, multiple application servers, or other infrastructure when you actually need it.
Start simple.
Understand every layer.
Then scale the architecture when the application gives you a reason to.
Top comments (0)