SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Host your AI-generated app on a VPS

Your agent wrote the app. Now keep it running: systemd supervision, a reverse proxy with TLS, an env file for secrets, backups, and safe redeploys.

What it takes to host an AI-generated app on a VPS

To host your AI-generated app on a VPS you need five things the agent did not write: a supervisor that restarts the process, a reverse proxy holding the certificate, secrets in a file outside the repository, a database on a disk that survives a reboot, and a way to ship a new version and take it back. The code is usually fine. The operations around the code do not exist yet.

That gap is not carelessness on the agent's part. It wrote code that runs on your laptop, and on your laptop you are the supervisor: you start the process, you watch the terminal, you press Ctrl-C when you are done. Nothing in the prompt said the process has to survive your SSH session closing, a reboot, a crash, and an out-of-memory kill at 03:00.

Work through the list top to bottom. Each step assumes the one above it is done. Skipping ahead to the certificate before the app runs under a supervisor means fixing the same thing twice.

Do the server basics before you copy any code up

A new VPS gets port-scanned within minutes of receiving a public IP, so the box comes first and the app comes second. Create a normal user with sudo rights, put your SSH key on it, turn off password login, and open only the ports you need. The full pass is in the first ten minutes on a new VPS, and it is much easier to do before anything valuable is on the machine.

sudo adduser deploy
sudo usermod -aG sudo deploy
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

ufw enable stops and asks Command may disrupt existing ssh connections. Proceed with operation (y|n)? before it commits. Answer yes only once OpenSSH is already in the allow list, because the default incoming policy becomes deny and an unopened port 22 locks you out of your own server. Keep a second SSH session open while you do this. ufw status verbose should then print Status: active followed by your allow rules.

Why does the app stop when I close the terminal?

Because nothing is holding it open. python app.py run over SSH is a child of your login shell. When the session ends, the shell receives SIGHUP and passes it to its children, so the app exits with it. A crash ends it the same way, and so does a reboot, and nothing brings it back.

tmux is the usual first answer, and it is half of one. A tmux session survives your logout. It does not survive a reboot, and it does not restart the process after a crash. The right tool is already installed: systemd starts your app at boot, restarts it when it exits, and captures its output.

Write /etc/systemd/system/app.service:

[Unit]
Description=My app
After=network-online.target
Wants=network-online.target

[Service]
User=deploy
Group=deploy
WorkingDirectory=/srv/app/current
EnvironmentFile=/etc/app.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/app/current/.venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 app:app
Restart=always
RestartSec=3
StateDirectory=app
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict

[Install]
WantedBy=multi-user.target

Load it and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now app
systemctl status app

systemctl status app should print Active: active (running) with a process ID below it. Active: failed (Result: exit-code) means the app exited on its own, so read the last lines with journalctl -u app -n 50 --no-pager. A status line reading (code=exited, status=203/EXEC) is different: systemd could not run the command at all. That happens because the path is wrong, the file is not executable, or you wrote a bare gunicorn. systemd only looks in a small built-in list of system directories, so a gunicorn that lives inside your virtualenv is never found. Give the absolute path.

enable is the half people forget. systemctl start app runs it now, systemctl enable app makes it come back after a reboot, and enable --now does both. Prove both halves rather than trusting them. Reboot the server, then run systemctl is-enabled app and systemctl is-active app, which should print enabled and active. For the crash case, read the process ID from systemctl show -p MainPID app, run sudo kill -9 <pid>, wait the three seconds of RestartSec, and check the status again. If the app is back, supervision works.

Two of those directives do quiet security work. ProtectSystem=strict mounts the filesystem read only for this unit, so the app cannot write next to its own code even if something in it tries. StateDirectory=app creates /var/lib/app, gives it to the service user, and makes it the one writable place the app has. If a bad request can also make the app eat memory, capping memory and CPU in the same unit file keeps one runaway process from taking the whole VPS down with it.

The dev server is not a web server

Agent-written projects nearly always start on a development server: flask run, uvicorn --reload, python manage.py runserver, npm run dev. Django says so itself every time it starts.

WARNING: This is a development server. Do not use it in a production setting. Use a production WSGI server instead.

These servers reload themselves when a file changes, handle very little concurrency, and in some frameworks ship an interactive debugger. Werkzeug's debugger runs Python that you type into a browser page, so a dev server with debug mode on and a public port is a remote shell for whoever finds it first.

Swap in a production server, and bind it to the loopback address. --bind 127.0.0.1:8000 means only processes on the same machine can connect, and nginx will be the only one that does. Check what is really listening:

ss -ltnp

The line for your app should read 127.0.0.1:8000. If it reads 0.0.0.0:8000, the app is accepting connections on every interface including the public one, and requests that arrive there skip nginx and skip TLS (transport layer security) completely. The Python pairing is covered end to end in the Django, Gunicorn and nginx deployment guide, and the shape is the same for Node or Go: build once with npm ci && npm run build so the lockfile decides the versions, then run the built output with HOST=127.0.0.1 set.

Put nginx in front and get a certificate

nginx holds the certificate and passes requests to your app on the loopback port. Write /etc/nginx/sites-available/app:

server {
    listen 80;
    server_name app.example.com;

    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;
    }
}
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

nginx -t prints syntax is ok and test is successful. Run it every time. A reload with a broken config fails and leaves the previous config serving, but a restart with a broken config leaves nginx stopped and your site down, so testing first is what keeps the two cases apart. What each line of that proxy block does is worth reading once, because the proxy_set_header lines are the difference between your app seeing real client addresses and seeing 127.0.0.1 for every visitor.

Now the certificate. Point a DNS A record at the VPS address first and wait until dig +short app.example.com returns that address, then:

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com

Certbot edits the server block, adds the listen 443 ssl half, and reloads nginx. It works by having Let's Encrypt fetch a file from your domain over port 80, which is why the two common failures are both about reachability. DNS problem: NXDOMAIN looking up A for app.example.com means the record is missing or has not propagated yet. Timeout during connect (likely firewall problem) means the name resolves but nothing answered on port 80: check sudo ufw status and then your provider's separate network firewall, which is a different control on most panels. Renewal runs from a timer, so confirm it with systemctl list-timers certbot.timer and sudo certbot renew --dry-run rather than finding out in ninety days. The full Certbot walkthrough for Ubuntu 24.04 covers the DNS-01 route as well, which you need for a wildcard certificate.

One thing surprises people right after TLS goes on. An app that redirects HTTP to HTTPS by itself can now loop forever: nginx receives HTTPS, forwards the request to your app as plain HTTP on the loopback, the app sees http, and it redirects to HTTPS again. The browser gives up and shows ERR_TOO_MANY_REDIRECTS. The fix is to make the app trust the X-Forwarded-Proto header that nginx is already sending: SECURE_PROXY_SSL_HEADER in Django, ProxyFix in Flask.

Get the secrets out of the repository

Agents put API keys straight in the source because that is what makes the code run on the first try. Check before you deploy anything:

grep -rnE "API_KEY|SECRET|PASSWORD|TOKEN" --exclude-dir=.git .
git log -p --all -S 'sk-' | head -n 40

If a key was ever committed, deleting the line does not remove it. git log -p still shows it, and anyone who cloned the repository already has it. Rotate that key at the provider, then put the new one in a file that systemd reads and git never sees.

sudo install -m 600 -o root -g root /dev/null /etc/app.env
sudo nano /etc/app.env
SECRET_KEY=replace-me
DATABASE_URL=sqlite:////var/lib/app/app.db
ANTHROPIC_API_KEY=replace-me

EnvironmentFile=/etc/app.env in the unit hands those values to the process, and mode 600 keeps every other user on the box out of the file. systemd is not a shell when it reads this file: lines are plain KEY=value, an export prefix makes the variable name invalid, and $OTHER is not expanded. A line that fails to parse is dropped and the service still starts, so the symptom is a config value that is empty at runtime rather than a service that refuses to boot. Add the local .env to .gitignore too, and run git rm --cached .env if it is already tracked.

If the app calls a model API, that key is now the most valuable thing on the server, and the same care applies as when you run a coding agent safely on a VPS: one key per machine, scoped as narrowly as the provider allows, rotated the moment it appears anywhere public.

Where does the database actually live?

SQLite is the default choice in agent-written apps because it needs no server. The trap is the path. A relative filename opens next to whatever the current working directory happens to be, so app.db lands inside the release directory and the next deployment leaves it behind with all of your data still in it. A path under /tmp is worse: /tmp is cleared at boot, and PrivateTmp=true gives the unit its own /tmp that is destroyed every time the service stops. Nothing logs an error when this happens, because SQLite simply creates a fresh empty file and carries on.

Use an absolute path under the state directory. In a SQLAlchemy URL that means four slashes, sqlite:////var/lib/app/app.db. Three slashes is a relative path, and it is by far the most common version of this bug.

Two more facts about SQLite on a server. It allows one writer at a time, so concurrent writes fail with database is locked; turn on write-ahead logging with PRAGMA journal_mode=WAL; and set a busy timeout in your driver. And write-ahead logging puts app.db-wal and app.db-shm next to the database, so copying app.db on its own while the app is running gives you a file that is missing recent commits. Take the snapshot properly:

sudo mkdir -p /var/backups
sudo sqlite3 /var/lib/app/app.db ".backup '/var/backups/app-$(date +%F).db'"

Logging: where does print() go?

Straight into the journal. systemd captures stdout and stderr from the unit, so print() and your framework's logger both land somewhere you can read:

journalctl -u app -f
journalctl -u app --since "10 minutes ago"

Python buffers stdout whenever it is not attached to a terminal, so without PYTHONUNBUFFERED=1 your log lines arrive in chunks or only when the process exits. That is why the unit file above sets it, and it is the reason a crashing app so often looks like it logged nothing at all.

Run journalctl --list-boots once. If only boot 0 appears, the journal lives in memory and every reboot throws away the logs that would have explained the crash. sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald makes it persistent, and SystemMaxUse=200M in /etc/systemd/journald.conf stops it from filling the disk. nginx logs somewhere else entirely: /var/log/nginx/error.log is where a 502 explains itself, usually as connect() failed (111: Connection refused) while connecting to upstream, which means nginx is running and your app is not.

Backups: the code is the replaceable part

The agent can write the code again. It cannot write your users' data again. Back up four things and you can rebuild the whole service on a fresh VPS: /var/lib/app, /etc/app.env, /etc/systemd/system/app.service, and your nginx server block.

sudo apt install -y restic
sudo mkdir -p /srv/backups
sudo restic init -r /srv/backups/restic
sudo restic -r /srv/backups/restic backup /var/lib/app /etc/app.env \
  /etc/systemd/system/app.service /etc/nginx/sites-available/app

restic init asks you to set a repository password, and losing that password loses the backup, so store it where you store the rest of your credentials. A copy on the same VPS protects you from a bad deployment and from nothing else: when the disk or the account goes, both copies go together. That is why sending the same backup to two repositories is the version worth the extra ten minutes.

Whatever you set up, restore it once on purpose before you need it. sudo restic -r /srv/backups/restic restore latest --target /tmp/restore-test, then actually open the database file it produced. A backup nobody has restored is an assumption.

How do I deploy a new version and roll it back?

This is the step that bites hardest, because whatever the agent wrote is going to change next week. Deploy into a new directory and flip a symlink. The previous version stays on disk, so going back is one command instead of a rescue operation.

Set repo to your own project's clone address first, so the block below is the same for everyone. Running git remote get-url origin inside the project on your laptop prints the value to paste.

ts=$(date +%Y%m%d%H%M%S)
sudo -u deploy git clone --depth 1 "$repo" /srv/app/releases/$ts
sudo -u deploy python3 -m venv /srv/app/releases/$ts/.venv
sudo -u deploy /srv/app/releases/$ts/.venv/bin/pip install \
  -r /srv/app/releases/$ts/requirements.txt
sudo -u deploy ln -sfn /srv/app/releases/$ts /srv/app/current
sudo systemctl restart app

Rolling back is the same flip, pointed at the directory you were on before:

ls -l /srv/app/current
sudo -u deploy ln -sfn /srv/app/releases/20260815120000 /srv/app/current
sudo systemctl restart app

The -n in ln -sfn carries real weight. Without it, ln -sf follows the existing current symlink and creates the new link inside the old release directory, so current still points at the old code and the deployment silently changes nothing. Run ls -l /srv/app/current after every flip and read the timestamp it prints.

WorkingDirectory and ExecStart are resolved when the service starts, so the restart is what picks up the new release. Be honest about the gap that leaves. systemctl restart stops the old process before the new one is listening, and during those one or two seconds nginx has nothing to connect to and returns 502 Bad Gateway. For a personal app that is acceptable. If it is not, run two units on two ports, point the nginx upstream at the new port, and use systemctl reload nginx, which starts fresh workers on the new config while the old workers finish the requests they are already holding.

Migrations are the part a symlink cannot undo. Rolling the code back does not roll the schema back, so a release that added one column and dropped another leaves old code running against a new database. Snapshot the database with the .backup command above immediately before every deployment, and prefer additive migrations: add the new column, stop writing to the old one, drop it a release later.

Keep the last few release directories and delete older ones by hand. Check df -h before you deploy, because a full disk fails a deployment in confusing ways: pip runs out of space partway through and leaves a release directory that looks complete and is not.

If the agent handed you a Dockerfile instead

Docker gives you the supervisor for free and takes nothing else off the list. Put restart: unless-stopped on each service in the compose file, which brings containers back after a crash and after a reboot once the Docker service itself is enabled. Making a compose stack come back on boot covers the part that catches people out, and running Docker on a VPS covers the install and the day-to-day commands.

One Docker-specific trap is worth knowing before you point a domain at anything. Publish the port on the loopback only:

ports:
  - "127.0.0.1:8000:8000"

A bare - "8000:8000" publishes on every interface, and Docker writes its own iptables rules ahead of the ones ufw manages, so a port you believe ufw is blocking answers the internet anyway. Test it from another machine with curl http://your.server.ip:8000 and expect the connection to be refused. Your data also needs a named volume or a bind mount to a real path, because a container's own filesystem is gone the moment you pull a new image.

The checklist

  1. Server hardened: a normal user with sudo, key-only SSH, ufw active with 22, 80 and 443 open.
  2. App runs under systemd bound to 127.0.0.1, enable --now done, and it survives both kill -9 and a reboot.
  3. nginx in front, nginx -t clean, certificate issued and renewing on its timer.
  4. Secrets in /etc/app.env at mode 600, and every key that was ever committed rotated at the provider.
  5. Database at an absolute path under /var/lib/app, snapshotted before each deployment.
  6. Journal persistent, journalctl -u app readable, nginx error log looked at once so you know where it is.
  7. Backups running somewhere that is not this VPS, and restored once as a test.
  8. Deployments flip a symlink, and you have run a rollback while nothing was wrong.

If you are still working out what to run and where, the wider list of what a VPS is actually good for is a better starting point than picking a plan and working backwards.

FAQ

Why does my app stop running when I close the SSH session?

The process is a child of your login shell, so it receives SIGHUP when the session ends and exits with it. tmux keeps it alive past logout but does not survive a reboot and does not restart it after a crash. Write a systemd unit with Restart=always and run sudo systemctl enable --now app. Then prove it: read the process ID from systemctl show -p MainPID app, run sudo kill -9 <pid>, and confirm systemctl status app shows active (running) again a few seconds later.

Do I need nginx if my app already serves HTTP?

Yes. Two reasons: the framework's built-in server has no certificate, and a development server listening on 0.0.0.0 can expose an interactive debugger that runs code for whoever reaches it. Let nginx hold the certificate and bind the app to 127.0.0.1:8000 where nothing outside the machine can connect. Run ss -ltnp and confirm the app's line reads 127.0.0.1:8000 rather than 0.0.0.0:8000.

My SQLite database keeps resetting. What is wrong?

The path is relative, or it is under /tmp. A relative filename opens next to the current working directory, so every new release directory gets its own empty database. /tmp is cleared at boot, and PrivateTmp=true in a unit gives the service a private /tmp that is destroyed every time the service stops. Move the file to an absolute path under /var/lib/app, created by StateDirectory=app, and remember that SQLAlchemy needs four slashes for an absolute path: sqlite:////var/lib/app/app.db.

How do I update the app without breaking it?

Deploy into a fresh directory, flip a current symlink with ln -sfn, then run sudo systemctl restart app. Rolling back is the same flip pointed at the previous directory, which is why the old releases stay on disk. Two caveats: the restart drops in-flight requests for a second or two and nginx returns 502 Bad Gateway during that window, and a schema migration does not roll back with the code, so snapshot the database with sqlite3 ... ".backup ..." right before every deployment.

Should I use Docker instead of systemd?

Either works. Docker Compose with restart: unless-stopped gives you supervision plus a repeatable environment, while systemd gives you one less moving part on a small VPS. The rest of the list is identical either way: a reverse proxy with a certificate, secrets outside the image, a volume for the data, and backups you have restored once. If you go with Docker, publish ports as 127.0.0.1:8000:8000, because Docker's iptables rules sit ahead of ufw and a bare 8000:8000 answers the internet.