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
- A Linux server you can SSH into (this guide uses Ubuntu or Debian commands, but the ideas carry over to almost any distro).
- Python 3 and the
venvmodule available. - A Flask app. We'll assume a file called
app.pywith a Flask object namedappinside it. - Sudo access, because we'll install packages and write a systemd unit.
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.



