Deploying a Python Flask app on a Linux VPS in India
11 min read · 25-Feb-2024
villagehosting.in team
25 February 2024
Flask's built-in development server is not safe for production — it's single-threaded, doesn't handle errors gracefully, and exposes debug information. This guide deploys a Flask application properly: Gunicorn as the application server, NGINX as the reverse proxy, systemd for process management, and Let's Encrypt for SSL.
Never run Flask's dev server in production
flask run is a development tool. It cannot handle concurrent requests, will crash on unhandled exceptions and not restart, and if DEBUG=True is set, exposes an interactive Python debugger to the internet that allows arbitrary code execution. Always use Gunicorn or uWSGI in production.
Architecture overview
Internet → NGINX (port 443) → Gunicorn (port 8000) → Flask app
↓
PostgreSQL / SQLite
NGINX handles SSL termination, serves static files directly, and proxies dynamic requests to Gunicorn. Gunicorn manages multiple worker processes that handle Flask requests. Flask sees plain HTTP from Gunicorn.
Prerequisites
- Ubuntu 22.04 VPS with at least 1 GB RAM
- A non-root user with sudo access
- Domain name pointed to your VPS IP
- Python application code (in a git repository or uploaded via SFTP)
Step 1: Install dependencies
sudo apt update && sudo apt upgrade -y
sudo apt install python3 python3-pip python3-venv nginx git -y
Verify Python version:
python3 --version # Should be 3.10 or higher
Step 2: Set up the application directory
Create a dedicated user for your application (security best practice — your app doesn't need sudo):
sudo useradd -m -s /bin/bash flaskapp
sudo su - flaskapp
Clone or copy your application:
# If using git
git clone https://github.com/yourusername/yourapp.git /home/flaskapp/app
# Or create the directory and upload files via SFTP
mkdir -p /home/flaskapp/app
Step 3: Create a virtual environment
Always use a virtual environment. Never install packages system-wide.
cd /home/flaskapp/app
python3 -m venv venv
source venv/bin/activate
Install your dependencies:
pip install --upgrade pip
pip install -r requirements.txt
pip install gunicorn # Add Gunicorn if not already in requirements.txt
Example requirements.txt for a typical Flask app:
Flask==3.0.0
gunicorn==21.2.0
SQLAlchemy==2.0.23
Flask-SQLAlchemy==3.1.1
python-dotenv==1.0.0
psycopg2-binary==2.9.9 # If using PostgreSQL
Step 4: Application structure and configuration
Your Flask app should read configuration from environment variables, not hardcoded values.
Recommended directory structure:
/home/flaskapp/app/
├── app/
│ ├── __init__.py # Application factory
│ ├── routes.py
│ ├── models.py
│ └── static/
│ ├── css/
│ └── js/
├── requirements.txt
├── wsgi.py # Entry point for Gunicorn
├── .env # Environment variables (not in git)
└── venv/
wsgi.py — Gunicorn's entry point:
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run()
app/__init__.py — Application factory pattern:
from flask import Flask
import os
from dotenv import load_dotenv
load_dotenv()
def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL')
app.config['DEBUG'] = False # Always False in production
# Register blueprints
from .routes import main
app.register_blueprint(main)
return app
.env file (keep outside of git — add to .gitignore):
SECRET_KEY=your-very-long-random-secret-key
DATABASE_URL=postgresql://dbuser:dbpassword@localhost/myappdb
FLASK_ENV=production
Generate a strong secret key:
python3 -c "import secrets; print(secrets.token_hex(32))"
Step 5: Test Gunicorn manually
Before setting up systemd, verify Gunicorn can serve your app:
cd /home/flaskapp/app
source venv/bin/activate
# Test with 3 workers
gunicorn --bind 0.0.0.0:8000 --workers 3 wsgi:app
Visit http://your-vps-ip:8000 in a browser. If your app loads, Gunicorn is working. Stop it with Ctrl+C.
Choosing worker count: A common formula is (2 × CPU cores) + 1. For a 1-core VPS: 3 workers. For 2 cores: 5 workers. For RAM-limited VPS, reduce workers — each Gunicorn worker consumes 50–100 MB RAM.
Step 6: Create a systemd service
systemd manages Gunicorn as a service — starts it on boot, restarts it if it crashes, and handles logging.
exit # Back to your sudo user
sudo nano /etc/systemd/system/flaskapp.service
[Unit]
Description=Gunicorn instance to serve Flask application
After=network.target
[Service]
User=flaskapp
Group=www-data
WorkingDirectory=/home/flaskapp/app
Environment="PATH=/home/flaskapp/app/venv/bin"
EnvironmentFile=/home/flaskapp/app/.env
ExecStart=/home/flaskapp/app/venv/bin/gunicorn \
--workers 3 \
--bind unix:/run/flaskapp.sock \
--access-logfile /var/log/flaskapp/access.log \
--error-logfile /var/log/flaskapp/error.log \
wsgi:app
[Install]
WantedBy=multi-user.target
Note: using a Unix socket (unix:/run/flaskapp.sock) instead of a TCP port is faster for local communication between NGINX and Gunicorn.
Create the log directory:
sudo mkdir -p /var/log/flaskapp
sudo chown flaskapp:flaskapp /var/log/flaskapp
Enable and start the service:
sudo systemctl daemon-reload
sudo systemctl start flaskapp
sudo systemctl enable flaskapp
sudo systemctl status flaskapp
You should see Active: active (running). The socket file /run/flaskapp.sock should now exist.
If it fails:
sudo journalctl -u flaskapp -n 50 --no-pager
Common causes:
- Module not found: check your virtual environment path in
ExecStart .envfile path wrong: checkEnvironmentFilepath- App import errors: check your Python code
Step 7: Configure NGINX
sudo nano /etc/nginx/sites-available/flaskapp
server {
listen 80;
server_name yourdomain.in www.yourdomain.in;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.in www.yourdomain.in;
ssl_certificate /etc/letsencrypt/live/yourdomain.in/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.in/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Serve static files directly (much faster than going through Flask)
location /static/ {
alias /home/flaskapp/app/app/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Proxy to Gunicorn
location / {
include proxy_params;
proxy_pass http://unix:/run/flaskapp.sock;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
# Required for correct IP logging
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;
}
}
Enable the site:
sudo ln -s /etc/nginx/sites-available/flaskapp /etc/nginx/sites-enabled/
sudo nginx -t # Test config
sudo systemctl reload nginx
Step 8: Install SSL with Certbot
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.in -d www.yourdomain.in
Certbot modifies your NGINX config to add SSL. Auto-renewal runs as a systemd timer — verify it:
sudo systemctl status certbot.timer
sudo certbot renew --dry-run
Step 9: Fix permissions for the Unix socket
NGINX's worker process runs as www-data. It needs permission to access the Gunicorn socket:
# Add www-data to the flaskapp group
sudo usermod -aG flaskapp www-data
# Ensure the socket is group-accessible
# Add this to the Gunicorn ExecStart line:
# --umask 007
Or set the socket group explicitly in your systemd file:
[Service]
...
ExecStartPost=/bin/chmod 660 /run/flaskapp.sock
ExecStartPost=/bin/chown flaskapp:www-data /run/flaskapp.sock
After any change: sudo systemctl restart flaskapp nginx
Step 10: Deploying updates
Create a deployment script:
sudo nano /home/flaskapp/deploy.sh
#!/bin/bash
set -e
APP_DIR="/home/flaskapp/app"
cd "$APP_DIR"
echo "Pulling latest code..."
git pull origin main
echo "Activating virtual environment..."
source venv/bin/activate
echo "Installing/updating dependencies..."
pip install -r requirements.txt
echo "Running database migrations (if using Flask-Migrate)..."
# flask db upgrade
echo "Restarting Gunicorn..."
sudo systemctl restart flaskapp
echo "Deployment complete"
sudo systemctl status flaskapp --no-pager
chmod +x /home/flaskapp/deploy.sh
For zero-downtime restarts, use Gunicorn's graceful reload:
# Send SIGHUP instead of full restart
sudo systemctl reload flaskapp
# or: kill -HUP $(cat /var/run/flaskapp.pid)
Database migrations during deployment
If you're using Flask-Migrate (Alembic), run flask db upgrade before restarting Gunicorn. Always test migrations on a staging server first. For tables with millions of rows, long-running ALTER TABLE can cause downtime — use online schema change tools like pt-online-schema-change for MySQL or the zero-downtime patterns built into PostgreSQL's ALTER TABLE ADD COLUMN.
Common production problems
502 Bad Gateway from NGINX:
Gunicorn isn't running or the socket path is wrong. Check: sudo systemctl status flaskapp and verify the socket file exists: ls -la /run/flaskapp.sock.
Static files returning 404:
The alias path in your NGINX config doesn't match your actual static directory path. Run ls /home/flaskapp/app/app/static/ to verify.
"Working outside of application context" errors:
These happen when Flask code runs outside a request. Wrap database operations in with app.app_context(): or use @app.before_request.
High memory usage:
Each Gunicorn worker loads the entire Python application. Reduce --workers if RAM is the constraint. Use --max-requests 1000 to recycle workers periodically and prevent memory leaks.
Slow cold start:
Flask apps with many imports take time to initialize. Gunicorn's --preload flag loads the app once in the master process and forks workers: gunicorn --preload --workers 3 .... This reduces per-worker startup time but means all workers share the initial app state.
Your Flask application is now production-ready: served by Gunicorn workers, proxied through NGINX, managed by systemd, and secured with HTTPS.