Deploying on VPS and Cloud Platforms
You have an image. You have a database. Now you need a place where they can run, on the internet, with a real URL. There are a lot of choices, and the best one depends less on technical capability than on how much operational work you actually want to own.
This page surveys the realistic options, with honest trade-offs. There is no "best" - there's "best for your team and your stage."
The hosting spectrum
more you manage ────────────────────────────────► less you manage
─────────────────────────────────────────────────────────────────────
bare metal VPS IaaS PaaS Serverless
─────────────────────────────────────────────────────────────────────
colocation Hetzner AWS EC2 Render AWS Lambda
DigitalOcean GCP Compute Fly.io Cloudflare Workers
Linode Azure VM Railway Vercel
Heroku
KoyebCost generally goes up as you move right, but so does the time you reclaim. A team of two shouldn't be running Kubernetes; a team of fifty shouldn't be ssh'ing into individual VPSes.
Option 1: A single VPS (DigitalOcean, Hetzner, Linode, etc.)
The "you own the box" approach. Best when:
- You're early, simple, and want to keep costs at $5-$20/month.
- You want to learn how the stack actually works.
- You don't mind being on the hook for OS updates and the occasional 2 AM page.
The flow is roughly:
1. provision a VM (Ubuntu LTS is fine)
2. SSH in, install Docker and docker-compose
3. copy your compose file, set up a .env with prod secrets
4. run `docker compose up -d`
5. point your DNS at the VM's IP
6. put nginx in front for TLS (next page covers this)A minimal production docker-compose.yml:
services:
api:
image: yourorg/yourapp:${TAG}
restart: always
env_file: .env.prod
ports:
- "127.0.0.1:8000:8000" # only bind to localhost; nginx is in front
depends_on:
db: { condition: service_healthy }
db:
image: postgres:16
restart: always
environment:
POSTGRES_USER_FILE: /run/secrets/db_user
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: app
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
retries: 5
secrets:
- db_user
- db_password
volumes:
db_data:
secrets:
db_user:
file: ./secrets/db_user
db_password:
file: ./secrets/db_passwordThe deploy script is laughably small:
#!/bin/bash
# deploy.sh
set -e
TAG=$(git rev-parse --short HEAD)
docker build -t yourorg/yourapp:$TAG .
docker push yourorg/yourapp:$TAG
ssh deploy@yourserver "cd /srv/app && TAG=$TAG docker compose up -d"What you sign up for: backups (you set them up), TLS renewal (certbot, but you watch it), OS patches (you apt upgrade periodically), monitoring (you install something). All learnable. All work.
Option 2: A platform-as-a-service (Render, Fly.io, Railway, Heroku, Koyeb)
The "git push and it's live" approach. Best when:
- You'd rather pay $20-100/month than spend an evening every two weeks on ops.
- Your team is small and you want one fewer thing to think about.
- The platform's pricing matches your traffic profile.
The shape:
1. connect your git repo
2. point at a Dockerfile (or let it infer Python)
3. set environment variables in the dashboard
4. push to main → it deploysA render.yaml example for the Render platform:
services:
- type: web
name: api
runtime: docker
plan: starter
dockerfilePath: ./Dockerfile
healthCheckPath: /healthz/live
envVars:
- key: DATABASE_URL
fromDatabase: { name: app-db, property: connectionString }
- key: SECRET_KEY
generateValue: true
- key: ENVIRONMENT
value: production
databases:
- name: app-db
plan: starter
postgresMajorVersion: 16What the platform handles for you: TLS, deploy pipelines, log aggregation, simple metrics, database backups, scaling between instances, zero-downtime deploys. What you give up: fine control, predictable cost at scale, the ability to do anything too unusual.
Fly.io specifically is worth a mention for FastAPI because it gives you Dockerfiles + a CLI + global edge deployment without much fuss. A fly.toml is essentially a deployment manifest:
app = "my-fastapi-app"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = true
min_machines_running = 1
[[http_service.checks]]
interval = "30s"
timeout = "5s"
method = "get"
path = "/healthz/live"fly deploy does the rest.
Option 3: Managed Kubernetes (EKS, GKE, AKS)
Best when:
- You're already running several services and the operational maturity of Kubernetes pays back.
- Your team has the headcount and skills to run it.
- You need fine control over scaling, networking, and multi-region.
Worst when:
- You're a small team treating Kubernetes as a status symbol rather than a tool.
For a FastAPI app, the rough shape is a deployment, a service, an ingress, and a config + secret object. A minimal deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 2
selector:
matchLabels: { app: api }
template:
metadata:
labels: { app: api }
spec:
containers:
- name: api
image: yourorg/yourapp:abc1234
ports:
- containerPort: 8000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef: { name: api-secrets, key: database_url }
- name: SECRET_KEY
valueFrom:
secretKeyRef: { name: api-secrets, key: secret_key }
readinessProbe:
httpGet: { path: /health, port: 8000 }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /healthz/live, port: 8000 }
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests: { cpu: "100m", memory: "256Mi" }
limits: { cpu: "500m", memory: "512Mi" }The honest take: don't pick Kubernetes for a single FastAPI service. Pick it when you already have five services to coordinate and the leverage is real.
Option 4: Serverless (AWS Lambda + API Gateway, Cloudflare Workers)
Best when:
- Traffic is spiky and you don't want to pay for idle capacity.
- Cold-start latency is acceptable for your use case.
- The fit with your code is genuine (small, stateless, fast).
FastAPI runs on Lambda via Mangum:
from mangum import Mangum
from app.main import app
handler = Mangum(app)Then you package the dependencies, ship to Lambda, and put API Gateway in front. The operational concerns get smaller but new ones appear: cold starts (1-3 seconds for a Python app with imports), 15-minute execution limit, no long-lived connections (WebSockets need special handling), debugging is different.
For genuinely sporadic APIs (admin endpoints called twice a day, webhooks), Lambda is great. For anything with steady traffic, a regular server is almost always cheaper and faster.
How to decide
A small flowchart:
Do you have <10 paying users / no revenue yet?
│
├── yes ──► single VPS or a free-tier PaaS. Spend zero time on infra.
│
└── no
│
├── Is your team < 5 engineers?
│ ├── yes ──► PaaS (Render, Fly, Railway). Pay for time, not iron.
│ └── no
│
├── Do you have multiple services already?
│ ├── yes ──► managed Kubernetes is genuinely the right tool
│ └── no ──► PaaS still wins unless you have a specific reason
│
└── Is your traffic extremely spiky (idle most of the time)?
└── consider serverless for the spiky bits, regular hosting for the restThe default for most projects: PaaS. The exception: when you've outgrown it for a specific, measurable reason.
A few cross-cutting concerns
Whatever you pick, a handful of things matter everywhere.
Database - almost always managed
Self-hosting Postgres on the same VM as your app is fine for very early stage. The moment it matters, move to a managed database (RDS, Cloud SQL, Render Postgres, Supabase, Neon, etc.). Backups, point-in-time recovery, failover, patching - none of that is your job anymore.
The cost difference is real ($15-30/mo for a small managed DB vs. "free" on your VM), but the time saved the first time you actually need backups pays for years of it.
TLS - never DIY
Use Let's Encrypt via certbot if you're on a VPS. Use the platform's built-in TLS if you're on a PaaS. Never run your own CA. Never use self-signed certs in production. Browsers and clients are increasingly hostile to anything that isn't a proper public TLS cert.
DNS and CDN
Cloudflare (free tier) in front of almost any deployment is a low-cost win. You get DDoS protection, basic WAF, edge caching, and DNS in one place. Just be aware that proxied DNS hides your origin IP - which is what you want for security, but trips people up when they're debugging.
Deploy automation
Don't ssh in to deploy. Even if you start by hand, write the deploy as a script the same day. CI that builds and pushes the image, then SSH'es or kubectl-applies the new tag, is fifteen lines of YAML.
# .github/workflows/deploy.yml (skeleton)
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t yourorg/yourapp:${{ github.sha }} .
- run: docker push yourorg/yourapp:${{ github.sha }}
- run: ssh deploy@server "TAG=${{ github.sha }} docker compose -f /srv/app/compose.yml up -d"A pricing sanity check
A rough order-of-magnitude for what hosting costs in 2025:
| Setup | Typical monthly |
|---|---|
| Hetzner / DigitalOcean VPS + managed DB | $20-50 |
| Fly.io / Render small app + DB | $25-100 |
| Heroku / similar with the polish tax | $50-200 |
| AWS Fargate + RDS + ALB (medium) | $150-500 |
| Managed Kubernetes (EKS/GKE) with multiple services | $300+ |
Prices move; ratios don't, much. The "right" tier is the one you can pay for now while still being able to grow into the next one.
A trap to avoid
Picking infrastructure for a hypothetical future. "We might need to scale to a million users so let's set up Kubernetes." If you don't have a million users today, you don't have a million users' worth of operational maturity either, and Kubernetes will eat the time you should be spending on the things that get you to that million.
A boring, simple deployment that you can actually maintain beats a glamorous, complex one you can't. Always.
What's next
Whichever platform you pick, you almost always end up with a reverse proxy in front of your app - for TLS, for static files, for routing, for many small operational reasons. The next page covers nginx specifically, because it's the proxy you'll meet most often whether you chose it or not.
How is this guide?
Last updated on
