Deploy a Python App with systemd and Nginx on VPS

What this setup gives you
Use this setup to deploy a Python app with systemd and Nginx on VPS when you want a clean production layout: one app process, one service manager, and one reverse proxy in front. It is easy to restart, simple to troubleshoot, and familiar if you are moving from staging to a live server.
For Hostperl customers, this pattern fits a small business site, an internal tool, or an API that must stay up after a reboot. If you are still sizing the server, a Hostperl VPS gives you enough room for Python, Nginx, logs, and a modest database without paying for more than you need on day one.
This guide starts from a fresh server, walks through a non-root admin account, installs the required packages, runs a sample Python app under systemd, and puts Nginx in front of it. It also includes the checks you should run before you call the deployment done.
Before you begin
Use a fresh Ubuntu, Debian, AlmaLinux, or Rocky Linux VPS. This tutorial covers both Debian-family and RHEL-compatible commands where they differ. You will need a domain pointed at the server before you can issue a public TLS certificate, but you can test the app by IP first.
On your local computer
ssh root@203.0.113.10203.0.113.10 is a reserved documentation example. Replace it with the real public IP assigned to your server. If your provider gives you a different default SSH account, use that first-login method, but keep the same documented IP in the examples below.
Check the operating system and prepare the server
On the VPS as root
cat /etc/os-releaseThis tells you whether the server uses apt or dnf. It also helps you choose the right firewall service later.
Update the machine, set the hostname, and install basic tools before you touch the app stack.
Ubuntu and Debian
apt update
apt -y upgrade
apt -y install sudo curl git ufw python3 python3-venv python3-pip nginxThese commands refresh package lists, apply updates, and install the runtime and web server. You should see the packages install cleanly, and Nginx should appear as a service.
hostnamectl set-hostname server.example.comReplace server.example.com with your actual hostname. A correct hostname makes logs easier to read and helps with mail or panel work later.
AlmaLinux and Rocky Linux
dnf -y update
dnf -y install sudo curl git firewalld python3 python3-pip nginxRHEL-compatible systems usually use dnf and firewalld. Python venv support normally comes with the standard Python package on current releases, but if your build lacks it, install the matching python3-venv package from the distribution repositories or use the bundled module after verification.
hostnamectl set-hostname server.example.comIf you are comparing platform fit for ongoing hosting, Hostperl’s managed VPS hosting is a practical starting point for a Python deployment that may later need Redis, PostgreSQL, or a second app service.
Create a non-root admin user and verify sudo
Keep the root session open until the new login works. That avoids a lockout if you mistype the SSH key or sudo setup.
Ubuntu and Debian
On the VPS as root
adduser deploy
usermod -aG sudo deployThis creates the deploy account and grants sudo access through the sudo group.
AlmaLinux and Rocky Linux
On the VPS as root
useradd -m deploy
passwd deploy
usermod -aG wheel deployHere you create the account, set a password, and add it to the wheel group. That is the standard sudo path on these systems.
Now add your SSH key. From your local computer, copy the public key into the new account by using ssh-copy-id if it is available:
ssh-copy-id deploy@203.0.113.10Replace 203.0.113.10 with your real server IP. If you prefer a manual method, log in as root and set up the key carefully:
sudo -iu deploy
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keysPaste your public key into the file, save, and exit. Then lock down the permissions:
chmod 600 ~/.ssh/authorized_keys
chown -R deploy:deploy ~/.sshOpen a second terminal on your local machine and test the new login before you change any SSH policy.
ssh deploy@203.0.113.10Then confirm sudo works:
sudo -v
whoamiYou should see deploy as the user and a sudo prompt only once every cache window.
If you want a reminder on user setup across distributions, Hostperl already covers the account workflow in How to Add a New Sudo User on Linux VPS Servers.
Open the firewall for SSH, HTTP, and HTTPS
Do this before you harden SSH so you do not lock yourself out. Open the web ports first, then confirm the rules are active.
Ubuntu and Debian with UFW
On the VPS as the non-root sudo user
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verboseYou should see SSH, port 80, and port 443 allowed. If UFW is already enabled, the status output should reflect the new rules immediately.
AlmaLinux and Rocky Linux with firewalld
On the VPS as the non-root sudo user
sudo systemctl enable --now firewalld
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
sudo firewall-cmd --list-allThese commands start the firewall, add the required services, reload the rules, and print the active zone. You should see SSH, HTTP, and HTTPS listed.
Install the Python runtime and create the app
For this tutorial, you will run a small Flask app on port 8000 behind Nginx. The same structure works for Gunicorn, FastAPI, or a Django project after you swap the app entry point.
On the VPS as the non-root sudo user
sudo mkdir -p /opt/myapp
sudo chown -R deploy:deploy /opt/myapp
cd /opt/myapp
python3 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install flask gunicornThis creates the application directory, sets ownership, makes a virtual environment, and installs the runtime packages inside it. Successful output ends with Flask and Gunicorn installed into /opt/myapp/.venv.
Create a simple app file:
nano /opt/myapp/app.pyFile content
from flask import Flask
app = Flask(__name__)
@app.get("/")
def home():
return {"status": "ok", "service": "Hostperl Python app"}
Save and exit. This sample returns JSON, which makes it easy to test the upstream path through Nginx.
Run a quick local process test before systemd takes over:
cd /opt/myapp
. .venv/bin/activate
gunicorn --bind 127.0.0.1:8000 app:appOpen another terminal on the VPS and confirm it answers:
curl http://127.0.0.1:8000/You should get JSON with status set to ok. Stop Gunicorn with Ctrl+C after the check.
For a deeper Python deployment pattern, Hostperl’s Python and Nginx deployment guide shows the same reverse-proxy model with a fuller app example.
Create a systemd service for the app
systemd keeps the app alive after a reboot and gives you one place to inspect logs. Use the same file path on every supported distribution.
On the VPS as root
nano /etc/systemd/system/myapp.serviceFile content
[Unit]
Description=Gunicorn service for myapp
After=network.target
[Service]
User=deploy
Group=deploy
WorkingDirectory=/opt/myapp
Environment="PATH=/opt/myapp/.venv/bin"
ExecStart=/opt/myapp/.venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 app:app
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
Save and exit, then test the unit file and start the service:
systemctl daemon-reload
systemctl enable --now myapp
systemctl status myapp --no-pagerThe status output should show active (running). If it does not, check the logs immediately:
journalctl -u myapp -n 50 --no-pagerPut Nginx in front of the app
Nginx listens on port 80 and passes requests to the Gunicorn socket on 127.0.0.1:8000. This is the part most visitors actually reach.
On the VPS as root
nano /etc/nginx/conf.d/myapp.confFile content
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
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;
}
}
Replace example.com with your real domain. Then test the Nginx syntax before you reload it.
nginx -t
systemctl enable --now nginx
systemctl reload nginxA successful syntax test prints that the configuration is OK. The reload should complete without error.
Now verify the proxy path from the server itself:
curl -I http://127.0.0.1/
curl http://127.0.0.1/You should see HTTP/1.1 200 OK and the JSON response from your Flask app.
If you want to compare web server behavior before you commit to one stack long term, Hostperl’s Nginx vs Apache article is a useful buying and deployment reference for site owners and agencies.
Add HTTPS with Let’s Encrypt
Once DNS points your domain to the VPS and Nginx is already serving the site on port 80, issue the certificate.
Ubuntu and Debian
On the VPS as root
apt -y install certbot python3-certbot-nginx
certbot --nginx -d example.com -d www.example.comChoose the redirect option when prompted. Certbot should update the Nginx server block and install the auto-renew timer.
AlmaLinux and Rocky Linux
On the VPS as root
dnf -y install certbot python3-certbot-nginx
certbot --nginx -d example.com -d www.example.comAfter issuance, test renewal without changing the live certificate:
certbot renew --dry-runThat dry run should complete with no errors. If renewal fails, review the certificate path and DNS records before you wait for expiry.
Final verification from server and client
On the VPS as root
systemctl status myapp --no-pager
systemctl status nginx --no-pager
ss -tulpn | grep -E ':80|:443|:8000'
journalctl -u myapp -n 20 --no-pagerYou want to see Gunicorn listening only on 127.0.0.1:8000, while Nginx owns ports 80 and 443.
On your local computer
curl -I http://example.com
curl https://example.com
curl https://example.com | jq .Replace example.com with your real domain. If jq is not installed locally, the last command can be replaced with plain curl. A healthy response should show HTTPS, a 200 status, and your JSON payload.
Reboot the server and confirm the service comes back on its own:
rebootAfter the VPS returns, reconnect and run:
systemctl status myapp --no-pager
systemctl status nginx --no-pagerBoth services should still be active. That is the real proof that your deployment survives routine maintenance.
Troubleshooting the most common failures
Gunicorn shows as failed
journalctl -u myapp -n 50 --no-pagerLook for import errors, a wrong module name, or a permission problem in /opt/myapp. Fix the Python file or the service unit, then run systemctl daemon-reload and systemctl restart myapp.
Nginx returns 502 Bad Gateway
curl http://127.0.0.1:8000/
ss -tulpn | grep 8000If the local curl fails, the app is not answering on the upstream port. Start by checking the systemd logs, then confirm the Gunicorn bind address in /etc/systemd/system/myapp.service.
HTTPS issuance fails
nginx -t
sudo ufw status verbose || sudo firewall-cmd --list-all
curl -I http://example.comIf the domain does not answer on port 80, fix DNS or firewall rules first. Let’s Encrypt needs a reachable HTTP endpoint for initial validation in this setup.
SSH key login does not work for deploy
ls -ld /home/deploy /home/deploy/.ssh
ls -l /home/deploy/.ssh/authorized_keysPermissions should be tight: .ssh at 700 and authorized_keys at 600. If they are broader, correct them with chmod and chown, then test again from a second terminal before closing root access.
If you want a production VPS with enough headroom for Python, Nginx, logs, SSL, and a future database, Hostperl can provision that without making the launch process more complicated. Start with a Hostperl VPS for the app layer, or choose dedicated server hosting if you expect heavier traffic or multiple services.
Hostperl’s team is used to real migrations, launch checks, and post-deploy support, so you are not left guessing when a certificate, firewall rule, or service restart needs attention.
FAQ
Can I replace Flask with Django or FastAPI?
Yes. Keep the same structure: virtual environment, systemd service, and Nginx reverse proxy. Only the app entry point and dependencies change.
Why bind Gunicorn to 127.0.0.1 instead of 0.0.0.0?
Binding to loopback keeps the app private. Nginx is the public entry point, which is safer and easier to audit.
Should I use Apache or Nginx here?
For this layout, Nginx is the simpler front end. If your site already runs Apache, compare the tradeoffs before moving, or use Apache as a proxy if your team is already standardised there.
Where should I keep app logs?
Start with journalctl -u myapp for service logs. If the app grows, add file-based app logging under /var/log/myapp with rotation.
What should I do before a major code update?
Test on staging, back up the current app directory, and confirm you can roll back the systemd unit and Nginx config if the new release misbehaves.
This is a practical way to deploy a Python app with systemd and Nginx on VPS without building a fragile setup. If you need support for the next stage, from scaling the app to moving it onto a stronger machine, Hostperl’s VPS plans and migration help are built for that kind of work.
