IPv4 & IPv6 Leasing - Any RIR, Any LocationOrder Now
Hostperl

Deploy a Python App with systemd and Nginx on VPS

By Raman Kumar

Share:

Updated on Aug 11, 2026

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.10

203.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-release

This 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 nginx

These 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.com

Replace 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 nginx

RHEL-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.com

If 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 deploy

This 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 deploy

Here 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.10

Replace 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_keys

Paste your public key into the file, save, and exit. Then lock down the permissions:

chmod 600 ~/.ssh/authorized_keys
chown -R deploy:deploy ~/.ssh

Open a second terminal on your local machine and test the new login before you change any SSH policy.

ssh deploy@203.0.113.10

Then confirm sudo works:

sudo -v
whoami

You 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 verbose

You 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-all

These 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 gunicorn

This 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.py

File 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:app

Open 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.service

File 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-pager

The status output should show active (running). If it does not, check the logs immediately:

journalctl -u myapp -n 50 --no-pager

Put 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.conf

File 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 nginx

A 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.com

Choose 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.com

After issuance, test renewal without changing the live certificate:

certbot renew --dry-run

That 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-pager

You 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:

reboot

After the VPS returns, reconnect and run:

systemctl status myapp --no-pager
systemctl status nginx --no-pager

Both 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-pager

Look 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 8000

If 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.com

If 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_keys

Permissions 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.