Como rodar apps Python com Gunicorn e Nginx em um VPS
Implantar um aplicativo Flask ou FastAPI no Ubuntu com um virtualenv, um serviço systemd Gunicorn, um proxy reverso Nginx, TLS, arquivos de ambiente, logging e um checklist para erros 502 e dimensionamento de workers.

O servidor Flask embutido e o uvicorn --reload são para desenvolvimento. Num VPS público você quer um gerenciador de processos que reinicie workers, faça bind em localhost e fique atrás de um proxy reverso que cuide de TLS e clientes lentos. O Gunicorn é a escolha WSGI usual para Flask e Django. O FastAPI pode rodar sob Gunicorn com uma classe de worker Uvicorn. O Nginx (ou o Caddy) encerra HTTPS e encaminha para 127.0.0.1:8000.
Este guia percorre um layout que sobrevive a reboots: projeto em /srv/app, virtualenv, .env com segredos, uma unidade gunicorn.service, um bloco server Nginx, Let's Encrypt e arquivos de log que você realmente consegue greppar. Também vamos falar de quantidade de workers, timeouts e por que 502 Bad Gateway quase nunca é «o Nginx quebrou» — quase sempre o Gunicorn não está rodando, está vinculado ao socket errado ou crasha no import. O exemplo usa Flask, com notas para FastAPI onde o comando muda.
Por que esta pilha
O Gunicorn pré-faz fork dos processos worker. Cada worker trata um request por vez, a menos que você use outra classe de worker. O Nginx faz buffer de clientes lentos para os workers não ficarem presos mandando bytes para uma rede móvel. O systemd reinicia o app se ele morrer. Junto, isso é chato, que é o que você quer às três da manhã.
- Gunicorn: modelo de processo WSGI/ASGI estável
- systemd: sobe no boot, reinicia no crash, logs journald
- Nginx: TLS, arquivos estáticos, limites de tamanho de request, gzip
- venv: o Python do sistema fica limpo
- bind em localhost: o app só é alcançável através do Nginx
Requisitos
Python 3.10+ no Ubuntu 22.04/24.04 serve. Não rode pip como root nos site-packages do sistema. Você precisa de um domínio para TLS. Se preferir o Caddy como proxy, a unidade Gunicorn deste artigo continua a mesma — só a config da frente muda (veja o artigo do Caddy).
- VPS Ubuntu 22.04 ou 24.04
- Seu aplicativo com um requirements.txt ou equivalente
- Um entrypoint WSGI (para Flask: app:app) ou ASGI (para FastAPI: app:app com workers uvicorn)
- Registro A do domínio para HTTPS
Passo 1: Pacotes do sistema, usuário e diretório do projeto
Crie um usuário de sistema que não consegue fazer login interativo, dono do código, e que rode o Gunicorn. Instalar python3-venv e ferramentas de build evita falhas de pip em pacotes que ainda compilam extensões C.
ssh root@YOUR_VPS_IP
apt update && apt -y upgrade
apt -y install python3 python3-venv python3-pip python3-dev build-essential nginx curl
adduser --system --group --home /srv/app appuser
mkdir -p /srv/app
chown appuser:appuser /srv/app
# Copy your code (example):
# rsync -a --delete ./myproject/ appuser@YOUR_VPS_IP:/srv/app/Passo 2: Virtualenv e dependências
Crie o venv como appuser para a propriedade dos arquivos ficar correta. Fixe versões em produção. Depois de instalar, confirme que você consegue importar o app com um gunicorn --check-config pontual ou um import Python. Erros de import aqui são os mesmos que depois viram 502.
sudo -u appuser -H bash -lc '
cd /srv/app
python3 -m venv /srv/app/venv
/srv/app/venv/bin/pip install --upgrade pip
/srv/app/venv/bin/pip install -r /srv/app/requirements.txt gunicorn
'
# Flask example check:
sudo -u appuser -H /srv/app/venv/bin/python -c "from app import app; print('import ok')"Passo 3: Arquivo de ambiente
Não grave SECRET_KEY ou URLs de banco na unidade systemd de um jeito que acabe num git legível por todo mundo. Use EnvironmentFile. chmod 640, dono root, grupo appuser (ou dono appuser se preferir).
cat >/srv/app/.env <<'EOF'
FLASK_ENV=production
SECRET_KEY=replace-with-openssl-rand-hex-32
DATABASE_URL=postgresql://app:password@127.0.0.1:5432/app
EOF
chown appuser:appuser /srv/app/.env
chmod 600 /srv/app/.envPasso 4: Serviço systemd Gunicorn
Faça bind em 127.0.0.1:8000, não em 0.0.0.0, a menos que você tenha motivo para pular o Nginx. A quantidade de workers costuma ser (2 × CPU) + 1 para workers sync; num VPS de 2 vCPU isso dá 5, o que pode ser demais se cada worker carregar um modelo ML pesado — aí use 2–3. Para FastAPI, defina --worker-class uvicorn.workers.UvicornWorker e instale uvicorn. Os timeouts devem passar do seu request honesto mais lento, não 30 segundos se você tem exports de 2 minutos.
cat >/etc/systemd/system/gunicorn.service <<'EOF'
[Unit]
Description=Gunicorn for the web app
After=network.target
[Service]
User=appuser
Group=appuser
WorkingDirectory=/srv/app
EnvironmentFile=/srv/app/.env
ExecStart=/srv/app/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 --timeout 60 --access-logfile - --error-logfile - app:app
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now gunicorn
systemctl status gunicorn --no-pager
ss -tulpn | grep 8000Passo 5: Proxy reverso Nginx e TLS
O Nginx escuta nas 80/443 e faz proxy para o Gunicorn. client_max_body_size importa em uploads. proxy_read_timeout deve igualar ou passar o timeout do Gunicorn. Quando o bloco server funcionar em HTTP, emita um certificado com o Certbot (ou passe a frente para o Caddy). O snippet abaixo é só HTTP para testar; depois rode o Certbot, que pode editar o arquivo.
cat >/etc/nginx/sites-available/app <<'EOF'
server {
listen 80;
server_name app.example.com;
client_max_body_size 20m;
location /static/ {
alias /srv/app/static/;
}
location / {
proxy_pass http://127.0.0.1:8000;
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;
proxy_read_timeout 90s;
}
}
EOF
ln -sf /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
nginx -t && systemctl reload nginx
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
apt -y install certbot python3-certbot-nginx
certbot --nginx -d app.example.comPasso 6: Arquivos estáticos, permissões e especificidades Flask/FastAPI
O Nginx deve servir arquivos estáticos se você puder; é mais rápido que o Gunicorn. Django collectstatic, Flask send_from_directory para apps minúsculos, ou um CDN depois. O appuser precisa conseguir ler a árvore. Se você usa FastAPI, instale uvicorn[standard] no venv e mude o ExecStart para usar UvicornWorker. Se usar sockets Unix em vez de TCP, aponte proxy_pass para o socket e alinhe permissões para o www-data escrever nele.
# FastAPI ExecStart example:
# ExecStart=/srv/app/venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 2 --bind 127.0.0.1:8000 app:app
# Unix socket variant:
# --bind unix:/run/gunicorn/app.sock
# Nginx: proxy_pass http://unix:/run/gunicorn/app.sock:
chown -R appuser:appuser /srv/appWorkers, memória e reloads sem downtime
Workers sync do Gunicorn são simples e bastam para APIs request/response leves em CPU. Se você precisa de muitas esperas de I/O lentas ao mesmo tempo, considere gevent ou um worker ASGI — meça, não chute. Cada worker carrega o seu app; RAM ≈ workers vezes o RSS do app. Um VPS de 2 GB com 8 workers de um app de 300 MB vai para swap e parece «lento aleatoriamente». systemctl reload gunicorn (HUP) consegue reiniciar workers com o código novo se você implantou arquivos no lugar; um restart completo fica mais claro quando as dependências mudam.
# After git pull / rsync of new code:
sudo -u appuser -H /srv/app/venv/bin/pip install -r /srv/app/requirements.txt
systemctl restart gunicorn
curl -I https://app.example.com/healthResolver 502 e crashes silenciosos
502 significa que o Nginx não conseguiu uma resposta válida do upstream. journalctl -u gunicorn -e é o primeiro comando. Causas comuns: nome module:app errado, chave .env faltando, Postgres parado, bind em 127.0.0.1 mas Nginx noutro host, SELinux (raro no Ubuntu) ou o app escutando só em IPv6. 504 é timeout. Loops 301 acontecem quando o app redireciona para HTTP enquanto o X-Forwarded-Proto é ignorado.
- systemctl status gunicorn — está ativo?
- journalctl -u gunicorn -n 100 — ImportError, env faltando, banco
- curl -v http://127.0.0.1:8000/ a partir do VPS — se isso falhar, o Nginx é inocente
- nginx -t e error.log — upstream prematurely closed connection
- ss -tulpn | grep 8000 — nada escutando
- Disco cheio — workers crasham de jeitos misteriosos
Segurança
O app nunca faz bind em público. Segredos ficam no .env. Mantenha o venv e o SO patchados. Não rode Gunicorn como root. Se você trata de logins, defina cookies de sessão Secure e SameSite e configure o framework para confiar em X-Forwarded-Proto só a partir do Nginx. Faça rate-limit das rotas de login no Nginx ou no app.
- bind em 127.0.0.1 ou um socket Unix
- chmod 600 .env
- User= não root no systemd
- TLS via Certbot ou Caddy
- Desative o modo debug e o auto-reload em produção
Dicas
- Adicione uma rota /health que verifica a conectividade do banco para Compose e load balancers
- Entregue assets estáticos com um cabeçalho cache-control no Nginx
- Use um worker ou fila separados (Redis + systemd) para e-mails e jobs pesados
- Fixe as versões de gunicorn e uvicorn no requirements.txt
- Tire um snapshot antes do primeiro corte para produção
Um app Python num VPS fica pronto para produção quando roda sob Gunicorn como serviço systemd, faz bind só em localhost e é alcançado via Nginx ou Caddy com TLS. Coloque os segredos num EnvironmentFile, dimensione workers pela RAM e não por fórmula de blog, e depure 502 primeiro no journal do Gunicorn. Com esse caminho documentado para o seu repositório, cada deploy seguinte é rsync ou git pull, pip install e systemctl restart gunicorn.