There are a hundred ways to ship a web app: platform-as-a-service, serverless functions, containers, "one-click" app platforms. They're great — right up until you hit a limit, a surprise bill, or a feature the platform simply won't let you run. That's why we still run a meaningful part of our production stack on a plain cloud VPS.
This guide shows the exact process we use. It is not theoretical: we run a live production app this exact way — a cloud VM, a Python application server, and nginx in front of it, all on an Ubuntu LTS droplet that has survived countless reboots and years of steady traffic. No containers required, no magic panel, just the same commands you'll copy below. If you're a beginner, follow the steps in order and you'll have a deployed app at the end.
Here is the honest stack, top to bottom. It's radically simpler than the marketing around it:
That's it. Every piece below maps to one of those.
You need a small Linux server you can SSH into from anywhere. Cloud providers rent these by the hour or month, and you only pay while they're running — spin it up, deploy, and shut it down when you don't need it.
DigitalOcean's basic droplets run Ubuntu LTS out of the box, and the entry tiers are around $4–6/month — well within reach for a first real deployment. You pick the region, choose Ubuntu LTS (24.04 is the current mainstream LTS as of 2026), and you get an IP address plus root access within a minute.
We run our own production VMs on DigitalOcean, so that's the service we'll point you to here. It's the same provider stack our live apps run on today.
Whatever provider you choose, write down three things: the server's IP address, the root password or SSH key, and the region/location you picked.
Open your terminal and connect to the server. On macOS and Linux, SSH is built in; on Windows, open PowerShell or install the OpenSSH client. Replace YOUR_SERVER_IP with your actual IP:
ssh root@YOUR_SERVER_IP
The first thing you do on any fresh Ubuntu LTS server is update the package list and upgrade what's already installed. This keeps your kernel and packages current:
sudo apt update && sudo apt upgrade -y
Do this on every new VPS before you deploy anything. A fresh droplet can have patches waiting.
Ubuntu LTS ships with Python 3, but you want the packaging tools and venv so you can isolate your app's dependencies. Install the essentials in one line:
sudo apt install -y python3 python3-pip python3-venv nginx git
Then create a directory for your app and set up an isolated virtual environment inside it. This keeps project dependencies separate from the system Python, which is the difference between "it works" and "it worked for 15 minutes then broke":
mkdir -p /var/www/myapp
cd /var/www/myapp
python3 -m venv venv
source venv/bin/activate
From here on, pip installs go into this environment only.
gunicorn is the production WSGI server that actually serves your Python app. Install it into your virtual environment, then install your app's dependencies too:
pip install gunicorn
pip install -r requirements.txt
Before you worry about anything fancy, start your app manually on a port — say 8000 — to confirm it actually works:
gunicorn --workers 3 --bind 0.0.0.0:8000 wsgi:app
Replace wsgi:app with the module and app-object name your framework uses (Flask exposes app, Django uses wsgi). With three workers, you handle concurrency out of the box.
Now your app is reachable at http://YOUR_SERVER_IP:8000 — but raw and insecure. That's fine for a smoke test. To confirm it's alive from your own machine:
curl -I http://YOUR_SERVER_IP:8000
If you get a 200 OK, your app runs. Next, let nginx own the public entry point.
nginx is a reverse proxy. It listens on ports 80/443, terminates HTTPS, and passes requests on to your gunicorn process. Your app never exposes itself to the public internet directly — which is a real security win, not a nicety.
Linux firewall first, then nginx. Allow only SSH, HTTP, and HTTPS (and the app port during testing):
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Now create an nginx site that proxies to your app on port 8000:
sudo tee /etc/nginx/sites-available/myapp > /dev/null <<'EOF'
server {
listen 80;
server_name YOUR_DOMAIN_OR_IP;
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;
}
}
EOF
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
nginx -t validates your config before it takes effect; always run it before reloading. The last line tests and reloads in one go. After this, typing your public address in a browser serves your app over plain HTTP.
In 2026, a web app without HTTPS is a red flag to browsers and users alike. Two clean paths:
sudo apt install -y certbot python3-certbot-nginx, then sudo certbot --nginx -d yourdomain.com.Either is legitimate. The point is the same: your users should see a padlock, and only nginx should ever see the gunicorn port directly.
Right now your app dies the moment the SSH session ends or the server reboots. A systemd service fixes both. Create a unit file that starts gunicorn on boot, restarts it if it crashes, and always runs in your app's virtual environment:
sudo tee /etc/systemd/system/myapp.service > /dev/null <<'EOF'
[Unit]
Description=My App (gunicorn)
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/var/www/myapp/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 wsgi:app
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
Break that down: enable registers the service to start at boot, --now also starts it immediately, and Restart=always means a crash gets an automatic restart. Check on it whenever you need to:
sudo systemctl status myapp
sudo journalctl -u myapp -f
The second command streams your app's logs in real time — the first thing to reach for when something isn't behaving.
Your deploy loop is now short and boring, which is exactly what you want in production. Every time you change code:
cd /var/www/myapp
git pull
source venv/bin/activate
pip install -r requirements.txt --quiet
sudo systemctl restart myapp
Pull the new code, install any new dependencies, restart the service. gunicorn picks up the change and nginx keeps serving without you touching a router, a firewall, or a certificate.
Rent a droplet (~$4–6/mo), SSH in, update the system, install Python + gunicorn + nginx, run your app on a port, proxy it through nginx, add HTTPS, and pin it to systemd so it survives reboots. This is the exact stack a live production app runs on at our company — nothing about it is aspirational. You can be up in under an hour even as a first-timer.
If you'd rather start faster, the Python Automation Scripts Pack includes ready-to-run CLI tools for health checks and ops monitoring — the kind of thing you'll want on that server once it's live. Grab it at https://novaboro.com/checkout/python-scripts-pack.
And if you're building the whole thing into a real business, the Build & Run an AI-Powered Software Company course walks through running a live software operation on a lean stack — the same deployment philosophy, end to end. See it at https://novaboro.com/checkout/ai-software-company-course.