How to Deploy a Python App (Django or FastAPI) to Your Own VPS
Deploying a Python web app to your own server is not hard, but it has more moving parts than people expect. Unlike a static site, a Python app needs a long-running process, a production-grade application server in front of it, a reverse proxy to terminate HTTPS, somewhere to put environment variables and secrets, and a plan for static files and database migrations. This guide walks the whole path for both Django and FastAPI, explains why each piece exists, and finishes with the one-command Docker path that collapses all of it into a single deploy.
What a Python App Actually Needs in Production
The development server you run locally — python manage.py runserver or uvicorn app:app --reload — is explicitly not for production. It is single-threaded-ish, reloads on file changes, and is not hardened. In production you replace it with three layers stacked in front of your code:
- •An application server (Gunicorn or Uvicorn) that runs multiple worker processes and keeps your app alive.
- •A reverse proxy (Nginx, Caddy, or Traefik) that terminates HTTPS, handles the public port 443, and forwards requests to the app server on a private port.
- •A process supervisor (systemd or Docker) that restarts the whole thing if it crashes or the server reboots.
Get those three right and everything else — env vars, static files, migrations — is configuration on top.
Gunicorn vs Uvicorn: WSGI vs ASGI
The single most common source of confusion. The difference comes down to whether your framework is synchronous (WSGI) or asynchronous (ASGI). Classic Django is WSGI. FastAPI is ASGI. Modern Django can run under ASGI too if you use its async views or channels.
| Framework | Standard | Run with |
|---|---|---|
| Django (classic) | WSGI | gunicorn |
| Django (async) | ASGI | gunicorn -k uvicorn.workers.UvicornWorker |
| FastAPI | ASGI | uvicorn or gunicorn+uvicorn workers |
| Flask | WSGI | gunicorn |
For Django, the production command is straightforward — point Gunicorn at your project's WSGI module and bind it to a local port:
gunicorn myproject.wsgi:application \
--bind 0.0.0.0:8000 \
--workers 3 \
--timeout 60For FastAPI, the cleanest production setup is Gunicorn managing Uvicorn workers — you get Gunicorn's process management with Uvicorn's async event loop:
gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--workers 3A good starting point for worker count is (2 × CPU cores) + 1. On a small VPS with 2 vCPUs that is 5 workers; do not blindly crank it higher, because each worker holds its own memory and database connections.
Put a Reverse Proxy in Front for HTTPS
Never expose Gunicorn or Uvicorn directly to the internet. They bind to a local port (8000 above) and a reverse proxy sits in front on ports 80 and 443, terminates TLS, and forwards traffic inward. The proxy is also where automatic certificates happen: tools like Caddy and Traefik request and renew Let's Encrypt certificates for you, so you never run certbot by hand or wake up to an expired cert. A minimal Caddy config for a Python app is two lines:
api.yourdomain.com {
reverse_proxy localhost:8000
}That is the entire HTTPS story with Caddy — it sees the domain, requests a certificate over the ACME HTTP-01 challenge, and renews it automatically. The only prerequisite is that the domain's DNS A record already points at the VPS so the challenge can validate.
Environment Variables and Secrets
Never commit secrets. A Python app in production reads its database URL, secret key, and API keys from the environment, not from a checked-in settings file. In Django, SECRET_KEY, DEBUG, ALLOWED_HOSTS, and the database connection should all come from os.environ:
import os
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")Two production must-dos people forget: set DEBUG=False (a live app with debug on leaks tracebacks and settings to the public), and put your real domain in ALLOWED_HOSTS or Django will reject every request with a 400. FastAPI has fewer of these footguns but the same rule applies — read config from the environment, not source.
Static Files and Database Migrations
These are the two steps that silently break Django deploys. In development Django serves CSS, JS, and admin assets for you. In production it does not — you collect them into one directory at build time and let the app (via WhiteNoise) or the proxy serve them:
python manage.py collectstatic --noinput
python manage.py migrate --noinputThe order matters and so does the timing. collectstatic runs during the build; migrate should run on deploy, after the new code is present but before (or as) the new workers start serving traffic. The fastest path to a broken admin page with no styling is forgetting collectstatic; the fastest path to a 500 on a fresh feature is forgetting migrate. FastAPI has no static-collection step, but if you use Alembic you run alembic upgrade head in the same deploy slot.
A Production Dockerfile
Containerizing the app makes every one of the steps above reproducible and removes the "works on my machine" gap. A clean Django image looks like this — and a FastAPI image is identical apart from the final command:
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "myproject.wsgi:application", \
"--bind", "0.0.0.0:8000", "--workers", "3"]Pin your Python version (3.12-slim, not just latest), pin your dependencies in requirements.txt, and copy requirements.txt before the rest of the code so Docker can cache the dependency layer and rebuilds stay fast.
The One-Command Path with AODE
Everything above is the manual route, and it is worth understanding even if you automate it. AODE runs this exact pipeline on your own VPS so you do not assemble it by hand. You connect a GitHub repo and it detects that the project is Python, generates the Dockerfile (Gunicorn for Django/Flask, Uvicorn workers for FastAPI), builds the image, and runs it behind Traefik with a Let's Encrypt certificate issued automatically for your domain.
Environment variables go in the dashboard instead of a hand-edited .env on the box. AODE detects your migration tool (Prisma, Django, Laravel) and gives you a one-click Run button — and every deploy is versioned, so a bad release is one click away from the previous image. If you need a database, AODE can provision a PostgreSQL instance and inject the connection string, so DATABASE_URL is wired up without you touching pg_hba.conf. The work that's left is writing the app.
Deployment Checklist
- 1.Choose your server: Gunicorn for WSGI (Django/Flask), Uvicorn workers for ASGI (FastAPI / async Django).
- 2.Bind it to a local port and set workers to
(2 × cores) + 1. - 3.Put a reverse proxy (Caddy/Traefik) in front for HTTPS with automatic Let's Encrypt certs.
- 4.Read all config and secrets from environment variables; set
DEBUG=Falseand a realALLOWED_HOSTS. - 5.Run
collectstaticat build andmigrateon deploy. - 6.Supervise the process with Docker or systemd so it restarts on crash and reboot.
Try AODE
One-time purchase, lifetime license, 14-day money-back guarantee. Deploy Python, Node, Go, and more from GitHub to your own VPS with automatic SSL and auto-deploy on push.
Related Articles
Last updated: June 2026