Skip to content
DevOps devops app-deployment 5 min read

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:

ComponentJobWhy you need it
Your Flask/Django appYour actual business logicThe thing you wrote
GunicornRuns many copies (workers) of your app and feeds them requestsHandles concurrency and crashes
NginxA reverse proxy: takes public traffic and forwards it inwardTLS/HTTPS, static files, buffering, security
systemdKeeps Gunicorn running and restarts it on boot or crashReliability

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 root or your login account.
  • Set workers to (2 x CPU cores) + 1 as a starting point, then tune under real load.
  • Pin exact versions in requirements.txt and install into a virtualenv so deploys are reproducible.
  • Use Restart=always in 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 -f and /var/log/nginx/error.log when debugging.
Last updated June 15, 2026
Was this helpful?