Deploying a Python App with Gunicorn
When you build a Python web app with Flask or Django, the framework comes with a built-in development server. It is great for coding on your laptop, but it was never meant to face the real internet. In production you put a proper application server called Gunicorn in front of your app, and then a web server called Nginx in front of Gunicorn. This page walks you through doing exactly that on a fresh Ubuntu 22.04 or 24.04 LTS server, step by step, with every command you actually run.
Why not use the dev server in production
When you run flask run or python manage.py runserver, you are using a development server. It is single-threaded by default, slow, and the framework authors explicitly warn against using it for real traffic. It can only handle one request at a time well, it has no protection against malformed requests, and it does not restart itself if it crashes.
A production setup splits the job into pieces, each doing one thing well:
| Component | Job | Why you need it |
|---|---|---|
| Your Flask/Django app | Your actual business logic | The thing you wrote |
| Gunicorn | Runs many copies (workers) of your app and feeds them requests | Handles concurrency and crashes |
| Nginx | A reverse proxy: takes public traffic and forwards it inward | TLS/HTTPS, static files, buffering, security |
| systemd | Keeps Gunicorn running and restarts it on boot or crash | Reliability |
A “reverse proxy” is a server that sits in front of your app and forwards incoming requests to it. Nginx is the reverse proxy here. A “worker” is one running copy of your app process; more workers means you can serve more requests at the same time.
What is WSGI
WSGI (Web Server Gateway Interface, pronounced “wiz-ghee”) is a standard Python interface that describes how a web server hands an HTTP request to a Python web app and gets a response back. Flask and Django both speak WSGI. Gunicorn is a “WSGI server”: it knows how to take a raw HTTP request from the network and call your app through this standard interface. Because WSGI is a shared standard, the same Gunicorn command works for almost any Flask or Django project.
When to use Gunicorn: any standard Flask or Django app handling normal web requests. When NOT to: if your app is fully asynchronous (uses async def views, WebSockets, or an ASGI framework like FastAPI), use an ASGI server such as Uvicorn instead. WSGI cannot handle long-lived async connections well.
Step 1: Install Python and create a virtualenv
A virtualenv (virtual environment) is an isolated folder holding the exact Python packages your app needs, separate from the system Python. This stops one project’s packages from breaking another. Always use one.
sudo apt update
sudo apt install -y python3 python3-venv python3-pip
Now create a dedicated user and put your app under /srv. Running the app as its own non-root user is a security best practice.
sudo adduser --system --group --home /srv/myapp myapp
sudo mkdir -p /srv/myapp
# copy or git-clone your project into /srv/myapp here, then:
cd /srv/myapp
sudo -u myapp python3 -m venv venv
sudo -u myapp venv/bin/pip install --upgrade pip
sudo -u myapp venv/bin/pip install -r requirements.txt
sudo -u myapp venv/bin/pip install gunicorn
Output:
Successfully installed gunicorn-23.0.0
Step 2: Run the app with Gunicorn manually
Before wiring up systemd, test that Gunicorn can serve your app. The format is gunicorn <module>:<callable>. For Flask, if your file is app.py and your app object is named app, the target is app:app. For Django, it is your project’s WSGI module, e.g. myproject.wsgi:application.
cd /srv/myapp
sudo -u myapp venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 app:app
Output:
[2026-06-15 10:12:03 +0000] [4821] [INFO] Starting gunicorn 23.0.0
[2026-06-15 10:12:03 +0000] [4821] [INFO] Listening at: http://127.0.0.1:8000 (4821)
[2026-06-15 10:12:03 +0000] [4821] [INFO] Using worker: sync
[2026-06-15 10:12:03 +0000] [4824] [INFO] Booting worker with pid: 4824
A common rule for worker count is (2 x CPU cores) + 1. Press Ctrl+C to stop the manual test once you see it listening.
Step 3: Create a systemd service
systemd is Ubuntu’s service manager. A “unit file” tells it how to start, stop, and restart your app, and how to bring it back after a reboot or crash. We will have Gunicorn listen on a Unix socket (a file-based connection like /run/myapp.sock) instead of a network port. A Unix socket is slightly faster and cannot be reached from outside the machine, so only Nginx talks to it.
Create /etc/systemd/system/myapp.service:
[Unit]
Description=Gunicorn instance to serve myapp
After=network.target
[Service]
User=myapp
Group=www-data
WorkingDirectory=/srv/myapp
Environment="PATH=/srv/myapp/venv/bin"
ExecStart=/srv/myapp/venv/bin/gunicorn \
--workers 3 \
--bind unix:/run/myapp.sock \
--umask 007 \
app:app
Restart=always
[Install]
WantedBy=multi-user.target
We set Group=www-data and --umask 007 so the Nginx user (www-data on Ubuntu) can read the socket file. Now enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
Output:
● myapp.service - Gunicorn instance to serve myapp
Loaded: loaded (/etc/systemd/system/myapp.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-06-15 10:15:41 UTC; 2s ago
Main PID: 5012 (gunicorn)
Tasks: 4 (limit: 4915)
enable makes it start on boot; --now also starts it immediately. View logs anytime with sudo journalctl -u myapp -f.
Step 4: Put Nginx in front
Install Nginx and create a site config that forwards requests to the socket.
sudo apt install -y nginx
Create /etc/nginx/sites-available/myapp:
server {
listen 80;
server_name example.com;
location / {
include proxy_params;
proxy_pass http://unix:/run/myapp.sock;
}
location /static/ {
alias /srv/myapp/static/;
}
}
Letting Nginx serve /static/ files directly is much faster than routing them through Python. Enable the site, test the config, and reload:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Output:
nginx: configuration file /etc/nginx/nginx.conf test is successful
Finally, open the firewall so traffic can reach Nginx. ufw (Uncomplicated Firewall) is Ubuntu’s simple firewall front-end.
sudo ufw allow 'Nginx Full'
Never expose Gunicorn’s port directly to the internet. Keep it bound to a Unix socket or
127.0.0.1. Only Nginx should be public, because it handles HTTPS, request limits, and slow-client buffering that Gunicorn alone does not.
Best Practices
- Bind Gunicorn to a Unix socket (or
127.0.0.1), never a public address — only Nginx faces the internet. - Run the app as a dedicated non-root user, not as
rootor your login account. - Set workers to
(2 x CPU cores) + 1as a starting point, then tune under real load. - Pin exact versions in
requirements.txtand install into a virtualenv so deploys are reproducible. - Use
Restart=alwaysin the systemd unit so the app recovers automatically from crashes. - Add HTTPS with Certbot (
sudo apt install certbot python3-certbot-nginx) once the site is reachable on port 80. - Watch logs with
journalctl -u myapp -fand/var/log/nginx/error.logwhen debugging.