Get your free server today! View Plans →
Home Plans Blog About Contact Panel Join Discord
Hosting

How to deploy a Python Flask app with Gunicorn and Nginx

Move your Flask app off the development server. This guide runs it under Gunicorn as a managed systemd service behind Nginx, so it survives crashes and reboots.

How to deploy a Python Flask app with Gunicorn and Nginx

By the end of this guide you'll have a Flask app running under Gunicorn as a managed service, sitting behind Nginx, that survives reboots and crashes. This is the same setup we run for small internal tools and the kind of thing you'd put a real domain in front of. It's aimed at anyone comfortable with a Linux shell and SSH who has a working Flask app but has only ever run it with flask run.

Why not just use Flask's built in server

When you run flask run or call app.run(), Flask prints a warning that says this is a development server. That warning is not decoration. The built in server (Werkzeug) handles one request at a time by default, has no process management, and was never written to face the open internet. It will fall over under any real traffic, and if the Python process dies, nothing brings it back.

So we split the job in two. Gunicorn is a proper WSGI server that runs several worker processes and handles concurrent requests. Nginx sits in front of Gunicorn, terminates connections from the public, serves your static files quickly, and passes the dynamic requests through. This is a boring, well understood stack, and boring is exactly what you want for something that has to stay up.

What you need before you start

For the examples, assume the project lives at /home/deploy/myapp and the Linux user running everything is deploy. Swap in your own user and path as you go.

Step 1: Get the project onto the server and make a virtualenv

Copy your code up however you like, with git clone, scp, or SFTP through your host's panel. Then change into the project folder and create a virtual environment. Keeping the app's Python packages in their own environment means you won't clash with system Python or another app on the same box.

cd /home/deploy/myapp
python3 -m venv venv
source venv/bin/activate

Your prompt should now show (venv) at the front. Install your app's requirements and Gunicorn into this environment. If you have a requirements.txt, use it. Either way, add gunicorn.

pip install --upgrade pip
pip install -r requirements.txt
pip install gunicorn

Don't forget to pin Gunicorn in your requirements file too, so a future fresh install matches. A quick pip freeze > requirements.txt after this handles it.

Step 2: Run Gunicorn by hand once

Before wiring up services and proxies, prove that Gunicorn can serve your app. The basic command names the workers, the module, and the app object. The pattern is gunicorn -w 3 app:app, where the part before the colon is the Python file (without .py) and the part after is the Flask object inside it.

gunicorn -w 3 -b 127.0.0.1:8000 app:app

You should see output like this:

[2024-05-10 12:01:33 +0000] [4821] [INFO] Starting gunicorn 21.2.0
[2024-05-10 12:01:33 +0000] [4821] [INFO] Listening at: http://127.0.0.1:8000 (4821)
[2024-05-10 12:01:33 +0000] [4821] [INFO] Using worker: sync
[2024-05-10 12:01:33 +0000] [4824] [INFO] Booting worker with pid: 4824

From another terminal on the same server, hit it with curl to confirm a response comes back.

curl http://127.0.0.1:8000/

If you get your page's HTML back, Gunicorn is happy. Stop it with Ctrl+C. We bound to 127.0.0.1 on purpose, so this port is not reachable from outside the machine. In a moment we'll switch from a port to a Unix socket, which is a little tidier for a local handoff to Nginx.

A quick word on workers. A common starting point is two times the number of CPU cores plus one. So a 2 core server lands around 5 workers. Don't just crank this to a huge number. Each worker is a full copy of your app in memory, and more workers than your CPU and RAM can handle just adds context switching and waste. Start small, watch memory, and adjust.

Step 3: Create a systemd service so it stays running

Running Gunicorn in your SSH session means it dies the moment you log out. A systemd service fixes that. It starts Gunicorn on boot, restarts it if it crashes, and gives you clean log access. Create the unit file:

sudo nano /etc/systemd/system/myapp.service

Paste this in, adjusting the user, paths, and app name:

[Unit]
Description=Gunicorn instance for myapp
After=network.target

[Service]
User=deploy
Group=www-data
WorkingDirectory=/home/deploy/myapp
Environment="PATH=/home/deploy/myapp/venv/bin"
ExecStart=/home/deploy/myapp/venv/bin/gunicorn \
          --workers 3 \
          --bind unix:/home/deploy/myapp/myapp.sock \
          app:app

[Install]
WantedBy=multi-user.target

A few things worth understanding here. We set the Group to www-data, which is the user Nginx runs as on Debian and Ubuntu. That shared group is how Nginx will be allowed to talk to the socket. The ExecStart line uses the full path to the gunicorn binary inside your venv, so systemd does not depend on any activated environment. And instead of a port, we --bind to a Unix socket file at myapp.sock inside the project. A socket is a file on disk that two local processes use to talk, with no network port involved.

Now load and start it.

sudo systemctl daemon-reload
sudo systemctl start myapp
sudo systemctl enable myapp

The enable command is what makes it come back after a reboot. Check that it's alive.

sudo systemctl status myapp

You want to see active (running) in green. You should also see the socket file appear:

ls -l /home/deploy/myapp/myapp.sock

If the service failed to start, jump to the troubleshooting section below. Logs from this service come from journalctl -u myapp, which we'll use to debug anything that misbehaves.

Step 4: Set up the Nginx server block

If Nginx isn't installed yet, install it.

sudo apt update
sudo apt install nginx

Now create a site config that forwards requests to the Gunicorn socket. Create the file:

sudo nano /etc/nginx/sites-available/myapp

Here is a working server block. Replace example.com with your domain, or use your server's IP for now if you don't have a domain pointed yet.

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

    location / {
        include proxy_params;
        proxy_pass http://unix:/home/deploy/myapp/myapp.sock;
    }

    location /static/ {
        alias /home/deploy/myapp/static/;
        expires 30d;
        access_log off;
    }
}

The first location block sends everything to Gunicorn over the socket. The include proxy_params line pulls in a set of sensible forwarding headers that Ubuntu ships by default, including the original Host and the client's real IP, so your app sees who actually made the request rather than just localhost.

The second block is a small but real win. It tells Nginx to serve anything under /static/ straight from disk, without ever bothering Gunicorn. Nginx is far faster at shipping CSS, JavaScript, and images than your Python workers are, and it frees those workers up for actual application logic. The alias points at your Flask app's static folder. The expires line lets browsers cache those files.

Enable the site by linking it into sites-enabled, test the config, then reload.

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

The nginx -t step is one you should never skip. It checks your config for syntax errors before you reload, so a typo doesn't take the whole web server down. A passing test looks like this:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

If you still have the default Nginx welcome site enabled and it's grabbing your requests, remove its symlink with sudo rm /etc/nginx/sites-enabled/default and reload again.

Step 5: Fix socket permissions and open the firewall

For Nginx to read the Gunicorn socket, the directory holding it and the socket itself need the right access. Because the service runs as group www-data, Nginx (also www-data) can reach the socket, but only if Nginx can also traverse into the folder. Home directories are often locked down to the owner. The cleanest fix is to let the group execute into the project path:

sudo chmod 750 /home/deploy
sudo chmod 750 /home/deploy/myapp

Some people prefer to put the socket somewhere neutral like /run/myapp.sock to avoid touching home directory permissions at all. That works too, just update both the --bind path in the service and the proxy_pass line in Nginx to match.

If you run a firewall, allow normal web traffic.

sudo ufw allow 'Nginx Full'

Now open your domain or server IP in a browser. You should see your Flask app, served by Nginx, backed by Gunicorn, with no port number in the URL. That's the whole point of the setup.

Step 6: Deploying updates later

When you push new code, the cycle is short. Pull the changes, install any new dependencies inside the venv, then restart the service. Gunicorn loads your code once at startup, so a restart is what picks up your edits.

cd /home/deploy/myapp
source venv/bin/activate
git pull
pip install -r requirements.txt
sudo systemctl restart myapp

You don't need to touch Nginx for a normal code change. Only reload Nginx when you actually edit its config.

Troubleshooting

502 Bad Gateway

This is the most common one, and it almost always means Nginx is up but Gunicorn is not answering. First check the service:

sudo systemctl status myapp
sudo journalctl -u myapp --no-pager -n 50

If Gunicorn crashed, the traceback will be right there in the journal. A typical cause is an import error or a missing dependency that you forgot to install in the venv. Fix the code or install the package, then restart. Also confirm the socket path in your Nginx config exactly matches the --bind path in the service. A single mismatched character gives you a 502.

Socket permission denied

If the journal or the Nginx error log (sudo tail -f /var/log/nginx/error.log) mentions permission denied on the socket, Nginx cannot reach the socket file. Check that the service Group is www-data and that the project directory allows the group to enter it (the chmod 750 from Step 5). After changing the group in the unit file, you must run sudo systemctl daemon-reload and restart the service for it to take effect.

The service won't start at all

Run sudo systemctl status myapp and read the error. Two classic mistakes are a wrong path to the gunicorn binary in ExecStart and a typo in the app reference. Remember the format is app:app, file name then object name. If your file is wsgi.py and the app is called application, it would be wsgi:application instead.

Changes don't show up

If you edited code and nothing changed, you almost certainly forgot to restart Gunicorn. It does not watch files in production. Run sudo systemctl restart myapp. During development you can add --reload to the Gunicorn command so it reloads on file changes, but leave that off in production because it costs performance.

How many workers should I actually use

Start with two times your CPU cores plus one and watch real behavior. If your app spends most of its time waiting on a database or an external API, you might get better throughput from async workers (--worker-class gevent) than from adding sync workers. If memory climbs, you have too many workers for the box. There's no single right number, only the right number for your hardware and your traffic.

Static files give a 404

Check that the alias path in the Nginx static block points at the real folder on disk and ends with a trailing slash, matching the trailing slash on location /static/. Also make sure Nginx's user can read those files. A quick ls -l on the static directory will tell you if permissions are the problem.

Wrapping up

You now have a Flask app that runs as a real service, restarts itself if it dies, comes back after a reboot, and hands static files off to Nginx so your Python workers stay focused on logic. The next sensible step is a free SSL certificate so the site runs over HTTPS, which Certbot can add to this exact Nginx config in a couple of minutes. After that, the same pattern (Gunicorn under systemd, Nginx in front) will carry you a long way. We use it ourselves on Bytte.cloud VPS boxes, and it has held up well for everything from tiny side projects to apps doing steady daily traffic.

Common questions

Why can't I just run my Flask app with flask run in production?

Flask's built in server handles one request at a time, has no process management, and was never meant to face the internet. Gunicorn runs multiple workers and handles real traffic, while systemd keeps it running and restarts it if it crashes.

Should I bind Gunicorn to a port or a Unix socket?

For a local handoff to Nginx on the same machine, a Unix socket is a little tidier and avoids using up a network port. Bind to a socket file with --bind unix:/path/to/myapp.sock and point Nginx at it with proxy_pass. A port like 127.0.0.1:8000 also works fine.

How many Gunicorn workers should I use?

A good starting point is two times your CPU core count plus one. Watch memory and adjust from there, since each worker is a full copy of your app. If your app waits a lot on databases or APIs, async workers like gevent may serve you better than adding more sync workers.

I'm getting a 502 Bad Gateway. What's wrong?

A 502 usually means Nginx is up but Gunicorn is not answering. Check the service with systemctl status and read journalctl -u myapp for a traceback, often an import error or missing dependency. Also confirm the socket path in your Nginx config exactly matches the bind path in the systemd service.

Do I need to restart anything when I deploy new code?

Yes. Gunicorn loads your code once at startup, so after pulling changes and installing any new dependencies you must run sudo systemctl restart myapp to pick them up. You only reload Nginx when you change its config, not for normal code updates.

DO
Daniel Okafor
Web Developer at Bytte.cloud

Part of the Bytte.cloud team. We run game servers, bots and websites for a living, and we write these guides from what we see day to day in support and on our own servers.

Want to try this on real hardware?

Bytte.cloud has free plans for game servers, bots and websites. No credit card, set up in seconds.

Start for free See the plans