Deploy Flask to Production: A Practical Guide

Khimananda Oli 7 min read Programming and Languages
Deploy Flask to Production: A Practical Guide

By Khimananda Oli | Last reviewed: August 2026

Running Flask’s built-in development server in production is a common mistake that leads to security vulnerabilities, poor performance, and inevitable downtime under load. To properly deploy Flask to production, you must place the application behind a production-grade WSGI server like Gunicorn and reverse proxy it with Nginx for TLS termination and static file handling. This guide provides the exact configuration steps I use when setting up compliant, audit-ready Python environments on Ubuntu servers.

Client / BrowserHTTPS :443NginxReverse ProxyTLS TerminationStatic FilesPort 80/443GunicornWSGI ServerUnix SocketFlask AppPython WSGISystemd manages Gunicorn lifecycle & restarts
Production architecture to deploy Flask to production: Nginx handles external traffic while Gunicorn processes WSGI requests via Unix socket.

How do you configure Gunicorn to deploy Flask to production?

Gunicorn is the industry-standard WSGI HTTP server for Python applications. Unlike Flask’s development server, it handles concurrency, process management, and graceful restarts. When I harden Ubuntu servers for compliance frameworks like SOC 2 or ISO 27001, Gunicorn’s predictable resource usage and logging capabilities make it auditable and reliable.

Install Gunicorn in an isolated virtual environment

Never install production dependencies globally. Create a dedicated virtual environment to prevent version conflicts and simplify dependency tracking during security audits.

sudo apt update
sudo apt install python3-venv python3-pip -y
mkdir -p /opt/flask-app
cd /opt/flask-app
python3 -m venv venv
source venv/bin/activate
pip install gunicorn flask

Create a production-ready Gunicorn configuration

A configuration file is superior to CLI flags because it is version-controlled, reviewable, and consistent across deployments. Save this as /etc/gunicorn/flask-app.py:

# /etc/gunicorn/flask-app.py
bind = "unix:/run/flask-app/flask-app.sock"
workers = 4
worker_class = "gthread"
threads = 2
timeout = 120
accesslog = "/var/log/flask-app/access.log"
errorlog = "/var/log/flask-app/error.log"
loglevel = "info"
user = "flask-app"
group = "www-data"
  • Unix sockets eliminate TCP overhead and prevent accidental external exposure of the WSGI port.
  • Worker count should typically be (2 × CPU cores) + 1. Adjust based on whether your workload is CPU-bound or I/O-bound.
  • gthread worker class provides threading within each worker, improving throughput for I/O-heavy Flask apps without the memory cost of additional processes.

How do you create a systemd service for Flask?

Systemd ensures your Flask application starts on boot, restarts on failure, and integrates with journalctl for centralized logging. This is non-negotiable for any system that must pass an infrastructure audit or maintain SLOs.

Define the systemd unit file

Create /etc/systemd/system/flask-app.service with explicit paths and security restrictions:

[Unit]
Description=Flask Application Gunicorn Service
After=network.target

[Service]
User=flask-app
Group=www-data
WorkingDirectory=/opt/flask-app
Environment="PATH=/opt/flask-app/venv/bin"
ExecStart=/opt/flask-app/venv/bin/gunicorn -c /etc/gunicorn/flask-app.py wsgi:app
Restart=always
RestartSec=5
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/run/flask-app /var/log/flask-app

[Install]
WantedBy=multi-user.target

Prepare runtime directories and activate the service

sudo mkdir -p /run/flask-app /var/log/flask-app
sudo chown flask-app:www-data /run/flask-app /var/log/flask-app
sudo systemctl daemon-reload
sudo systemctl enable --now flask-app.service
sudo systemctl status flask-app.service

The ProtectSystem=strict directive makes the filesystem read-only except for explicitly declared paths. This follows the principle of least privilege and limits blast radius if the application is compromised. For deeper context on service hardening, see my guide on systemd services and timers.

SystemdGunicorn MasterFlask WorkersExecStartFork WorkersJournalctlSocket FileLog FilesSystemd enforces restart policies and captures stdout/stderr automatically
Systemd lifecycle when you deploy Flask to production: automatic restarts, socket creation, and integrated logging.

How do you configure Nginx as a reverse proxy for Flask?

Nginx terminates TLS, serves static files directly (bypassing Python entirely), buffers slow clients, and provides rate limiting. Exposing Gunicorn directly to the internet bypasses these protections and is a frequent finding in penetration tests.

Write the Nginx server block

Create /etc/nginx/sites-available/flask-app:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    root /opt/flask-app/static;

    location /static/ {
        alias /opt/flask-app/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location / {
        proxy_pass http://unix:/run/flask-app/flask-app.sock;
        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_redirect off;
        proxy_buffering on;
        proxy_request_buffering on;
    }
}

Enable the site and validate configuration

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

Always run nginx -t before reloading. A syntax error in production config can take down all sites on the server. For certificate provisioning, refer to setting up free SSL with Let's Encrypt.

What are the critical security steps when deploying Flask to production?

Security is not optional add-on work; it is foundational to a valid production deployment. Every item below addresses real vulnerabilities I have remediated during audit preparations.

  1. Dedicated service account: Run Gunicorn as flask-app, never as root. Restrict file ownership so only this user can write to application directories.
  2. Environment variable isolation: Store secrets in /etc/flask-app/env with mode 0600, owned by flask-app. Load them via EnvironmentFile= in systemd, never in source code.
  3. Firewall rules: Allow only ports 80 and 443 inbound. Block direct access to the Gunicorn socket or any debug ports. Use UFW or nftables consistently.
  4. Disable debug mode: Ensure FLASK_DEBUG=0 and FLASK_ENV=production. Debug mode exposes interactive debuggers that allow arbitrary code execution.
  5. Dependency pinning: Use pip freeze > requirements.txt or Poetry lock files. Unpinned dependencies introduce supply chain risk and break reproducibility.
  6. Log rotation: Configure logrotate for /var/log/flask-app/*.log. Unrotated logs consume disk space and may leak sensitive data over time.
ComponentDevelopment ServerProduction (Gunicorn + Nginx)
ConcurrencySingle-threaded, single-processMulti-worker, multi-threaded
TLS SupportNone (requires ad-hoc certs)Nginx terminates TLS with modern ciphers
Static FilesServed through Python (slow)Served directly by Nginx (fast)
Process ManagementManual restart requiredSystemd auto-restart on crash
Security HeadersNot configured by defaultNginx adds HSTS, CSP, X-Frame-Options
Audit TrailConsole output onlyStructured logs via journald + files
Development Server✗ No TLS encryption✗ Single process bottleneck✗ Debug mode enabled✗ Runs as current user✗ No log persistence✗ Static files via PythonProduction Deployment✓ TLS 1.3 via Nginx✓ Multi-worker Gunicorn✓ Debug mode disabled✓ Dedicated service account✓ Journald + logrotate✓ Nginx serves static filesVS
Security and performance comparison when you deploy Flask to production versus running the development server.

Deploy Flask to Production: Final Checklist and Next Steps

When you deploy Flask to production correctly, the result is a resilient, secure, and observable application that withstands traffic spikes and satisfies compliance requirements. Verify every item on this checklist before considering the deployment complete:

  • Gunicorn runs as a non-root user via systemd with Restart=always
  • Nginx terminates TLS with modern protocols and forwards headers correctly
  • Unix socket connects Nginx to Gunicorn (no exposed TCP ports)
  • Secrets are loaded from a protected environment file, not hardcoded
  • Debug mode is disabled and FLASK_ENV=production is set
  • Logs are persisted, rotated, and accessible via journalctl
  • Firewall allows only ports 80 and 443
  • Dependencies are pinned and installed in an isolated virtual environment

If your team needs help designing a production-grade Python deployment pipeline, implementing observability with OpenTelemetry, or preparing infrastructure for SOC 2 or ISO 27001 audits, reach out to discuss your specific requirements. I work with teams across Nepal and globally to build systems that are secure, scalable, and audit-ready from day one.

Frequently Asked Questions

Gunicorn remains the industry standard for synchronous Flask deployments due to its stability and simple configuration. For async workloads or WebSocket support, Uvicorn with uvloop offers superior performance. Avoid using the built-in Werkzeug development server in production as it lacks security hardening and concurrency handling.

No.

Never commit secrets to version control. Use systemd EnvironmentFile directives, Docker secrets, or cloud-native secret managers like AWS Secrets Manager. Load variables at runtime via python-dotenv only during local development. Production systems should inject configuration through the process manager or container orchestrator to prevent leakage in logs or images.

This typically indicates the reverse proxy cannot reach the Gunicorn socket or port. Verify the upstream path matches your systemd unit file, check Gunicorn worker timeouts against long-running requests, and inspect journalctl logs for permission issues on Unix sockets. Restarting both services often resolves transient connection failures after deployment updates.

No.

Start with two workers per CPU core plus one for synchronous apps. Monitor memory usage closely since each worker loads a full application copy. For IO-bound tasks, increase workers or switch to gthread/gevent classes. Always benchmark with realistic traffic patterns rather than relying solely on theoretical formulas for optimal sizing.

Configure SQLAlchemy pool_size based on available database connections divided by total app instances. A common starting point is five to ten connections per worker. Enable pool_pre_ping to handle stale connections after network interruptions. Monitor pg_stat_activity or equivalent metrics to avoid exhausting database resources during peak load periods.

Terminate TLS at the reverse proxy layer using Caddy or Nginx. Caddy automates certificate renewal via Let's Encrypt by default. Configure the proxy to forward X-Forwarded-Proto headers so Flask generates correct URLs. Never expose Flask directly to the internet on port 443 as it cannot handle certificate management efficiently.

Yes.

Serve assets through Nginx or Caddy using alias directives pointing to your static directory. Configure aggressive cache headers for immutable fingerprinted files. Offload heavy media to object storage like S3 with CloudFront CDN. Never let Gunicorn serve static content as it blocks Python workers and degrades dynamic request throughput significantly.

Configure structured JSON logging via structlog or python-json-logger for machine parsing. Rotate logs using logrotate with size-based policies rather than time-based alone. Ship logs to centralized platforms like Loki or Datadog to avoid local storage bloat. Set appropriate log levels per environment to reduce noise while preserving critical error context.

Use Celery with Redis or RQ for reliable task processing. Run workers as separate systemd services independent from web processes. Implement retry logic with exponential backoff for transient failures. Monitor queue depth and worker health via Flower or Prometheus exporters to detect bottlenecks before they impact user-facing response times.

Create a lightweight /health route returning 200 OK without database queries for liveness probes. Add a separate /ready endpoint verifying downstream dependencies for readiness checks. Keep responses fast under fifty milliseconds. Configure load balancers and Kubernetes to use these distinct endpoints preventing traffic routing to uninitialized or degraded instances.

Scan repositories with tools like gitleaks before pushing code. Use pre-commit hooks to block accidental credential commits. Rotate compromised keys immediately upon detection. Audit access logs regularly and restrict secret manager permissions using least privilege principles across all deployment pipelines and developer environments.

Instrument applications with OpenTelemetry SDKs exporting to Grafana Tempo or Jaeger. Track business metrics via Prometheus client library exposing custom counters and histograms. Set up alerting on error rates, latency percentiles, and saturation signals. Correlate traces with logs using consistent request IDs spanning frontend, Flask, and backend service boundaries.