Reference Guide Detailed deployment notes with production context and concrete examples.

Flask Static and Media Files Production Setup

If you're trying to serve Flask static assets and uploaded media files correctly in production, this guide shows you how to set them up step-by-step. The goal is to let Nginx serve files directly, keep Gunicorn focused on the app, and prevent missing asset or upload errors.

Quick Fix / Quick Setup

Create dedicated static and media directories, map them in Nginx with alias, and keep application traffic proxied to Gunicorn.

bash
sudo mkdir -p /var/www/myapp/static /var/www/myapp/media
sudo chown -R www-data:www-data /var/www/myapp

# Example Nginx locations
sudo tee /etc/nginx/sites-available/myapp >/dev/null <<'EOF'
server {
    listen 80;
    server_name example.com;

    location /static/ {
        alias /var/www/myapp/static/;
        access_log off;
        expires 7d;
    }

    location /media/ {
        alias /var/www/myapp/media/;
        access_log off;
        expires 1d;
    }

    location / {
        proxy_pass http://unix:/run/gunicorn.sock;
        include proxy_params;
    }
}
EOF

sudo nginx -t && sudo systemctl reload nginx

Use alias with a trailing slash, make sure the directories exist, and point Flask-generated URLs to /static/... and /media/....

What’s Happening

In production, Nginx should serve static files and uploaded media files directly instead of routing those requests through Gunicorn.

  • Static files are application assets such as CSS, JavaScript, fonts, and images bundled with the app.
  • Media files are user-generated uploads that must be stored in a writable directory and exposed through a dedicated URL path.
  • Broken static or media delivery usually comes from wrong paths, wrong Nginx alias usage, missing files, or permission problems.

Step-by-Step Guide

  1. Define stable filesystem paths for static and media files.
    Use directories outside rotating release paths.
    bash
    sudo mkdir -p /var/www/myapp/static /var/www/myapp/media
    
  2. Set ownership and permissions.
    Nginx must be able to read both directories. Your app process must be able to write uploads to the media directory.
    bash
    sudo chown -R www-data:www-data /var/www/myapp
    sudo find /var/www/myapp -type d -exec chmod 755 {} \;
    sudo find /var/www/myapp -type f -exec chmod 644 {} \;
    
  3. Configure Flask path settings.
    In Flask, keep the URL paths and disk paths explicit.
    python
    import os
    
    STATIC_URL = "/static"
    MEDIA_URL = "/media"
    STATIC_ROOT = "/var/www/myapp/static"
    MEDIA_ROOT = "/var/www/myapp/media"
    
    app.config["UPLOAD_FOLDER"] = MEDIA_ROOT
    

    If you generate media URLs in templates or responses, make sure they resolve under /media/....
  4. Copy application static assets into the production static directory.
    If your project stores assets in app/static/, sync them during deployment.
    bash
    rsync -av ./app/static/ /var/www/myapp/static/
    
  5. Save uploaded files into the media directory.
    Example upload handling:
    python
    import os
    from flask import request
    from werkzeug.utils import secure_filename
    
    app.config["UPLOAD_FOLDER"] = "/var/www/myapp/media"
    
    @app.post("/upload")
    def upload_file():
        file = request.files["file"]
        filename = secure_filename(file.filename)
        file.save(os.path.join(app.config["UPLOAD_FOLDER"], filename))
        return {"filename": filename}, 201
    
  6. Update template and application URLs.
    Static files should resolve under /static/.... Uploaded files should resolve under /media/....
    Example:
    html
    <link rel="stylesheet" href="/static/css/app.css">
    <img src="/media/example.jpg" alt="">
    
  7. Configure Nginx to serve static and media files directly.
    Use alias, not proxy_pass, for these paths.
    nginx
    server {
        listen 80;
        server_name example.com;
    
        location /static/ {
            alias /var/www/myapp/static/;
            access_log off;
            expires 7d;
            add_header Cache-Control "public";
        }
    
        location /media/ {
            alias /var/www/myapp/media/;
            access_log off;
            expires 1d;
            add_header Cache-Control "public";
        }
    
        location / {
            proxy_pass http://unix:/run/gunicorn.sock;
            include proxy_params;
        }
    }
    
  8. Enable the Nginx site if needed.
    bash
    sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
    
  9. Validate and reload Nginx.
    bash
    sudo nginx -t
    sudo systemctl reload nginx
    
  10. Test static file serving directly.
bash
echo 'ok' | sudo tee /var/www/myapp/static/health.txt
curl -I http://127.0.0.1/static/health.txt

Expected result: HTTP/1.1 200 OK

  1. Test media file serving directly.
bash
echo 'upload-test' | sudo tee /var/www/myapp/media/sample.txt
curl -I http://127.0.0.1/media/sample.txt

Expected result: HTTP/1.1 200 OK

  1. Check SELinux or service restrictions if applicable.

On hardened systems, Nginx may still fail even when Unix permissions look correct. Verify read access for Nginx and write access for the app service to the media directory.

  1. Add static sync and media preservation to your deploy process.

Static files should be copied on every deploy. Media files should remain in persistent storage and not be replaced by a new release.

  1. Use persistent storage for containers.

If you deploy with containers, mount a volume for uploads.

yaml
volumes:
  - /var/www/myapp/media:/app/media

Common Causes

  • Wrong Nginx directive → Using root instead of alias for nested paths can map requests to the wrong directory → Use alias /full/path/; inside location /static/ and location /media/.
  • Missing trailing slashalias without the expected trailing slash can produce bad path resolution → Keep both the location and alias forms consistent.
  • Files were never copied → Static assets exist in the repo but not in the production directory → Sync or collect them during deployment.
  • Uploads saved to the wrong directory → Flask writes files somewhere other than the Nginx-exposed media path → Set UPLOAD_FOLDER to the production media directory.
  • Permission denied → Nginx cannot read files or the app cannot write uploads → Fix ownership and mode bits for the service users.
  • Requests hitting Gunicorn instead of Nginx static locations → Nginx location blocks are missing or shadowed by another rule → Put explicit /static/ and /media/ locations in the server block.
  • URL mismatch → App generates /uploads/... while Nginx serves /media/... → Make the Flask URL path and Nginx location path match exactly.
  • Ephemeral deploy path → Static or media files are stored inside a release directory that gets replaced → Move them to stable paths under /var/www or mounted storage.

Debugging Section

Check configuration, files, permissions, and request routing.

Nginx config validation

bash
sudo nginx -t
sudo nginx -T | less

What to look for:

  • syntax errors
  • duplicate server blocks
  • missing /static/ or /media/ locations
  • wrong alias paths

Nginx logs

bash
sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/nginx/access.log

What to look for:

  • 404 for files that do not exist on disk
  • 403 caused by permissions
  • requests to /static/ or /media/ being handled unexpectedly

File existence and path traversal

bash
ls -lah /var/www/myapp/static
ls -lah /var/www/myapp/media
namei -l /var/www/myapp/static/health.txt

What to look for:

  • missing files
  • unreadable parent directories
  • incorrect ownership on /var, /var/www, or app subdirectories

Local request tests

bash
curl -I http://127.0.0.1/static/health.txt
curl -I http://127.0.0.1/media/sample.txt

What to look for:

  • 200 OK means Nginx can resolve and serve the file
  • 404 means the URL-to-filesystem mapping is wrong or the file is missing
  • 403 means permissions are blocking access

Gunicorn logs

bash
sudo journalctl -u gunicorn -n 100 --no-pager

What to look for:

  • upload write failures
  • path misconfiguration in Flask
  • attempts to handle static URLs in the application instead of Nginx

Checklist

  • /static/ is mapped in Nginx with alias to the correct absolute directory.
  • /media/ is mapped in Nginx with alias to the correct absolute directory.
  • Static files exist in the production static directory.
  • Uploaded files are written to the production media directory.
  • Nginx can read both static and media directories.
  • The app process can write to the media directory.
  • nginx -t passes without errors.
  • A direct request to /static/health.txt returns 200.
  • A direct request to a sample file under /media/ returns 200.
  • Static and media paths are preserved across deployments.

FAQ

Q: Should Flask serve static files in production?
A: No. Let Nginx serve static and media files directly for better performance and simpler routing.

Q: What is the difference between static and media files?
A: Static files are app assets shipped with the codebase. Media files are user uploads created after deployment.

Q: Should media files live inside the app release directory?
A: Usually no. Store them in a stable persistent path outside rotating release folders.

Q: Why are my uploads saving but not loading?
A: The app write path and Nginx read path are usually different, or Nginx lacks permission to read the files.

Q: Should Nginx or Gunicorn serve static files?
A: Nginx should serve static and media files directly in production.

Q: What path should uploaded files use?
A: Use a persistent writable directory such as /var/www/myapp/media, not a temporary release path.

Q: Why do static files work locally but fail in production?
A: Local Flask development serving can hide missing Nginx mappings, missing copied files, or production permission issues.

Q: Should static and media use the same directory?
A: No. Keep them separate so app assets and user uploads are managed independently.

Final Takeaway

Production Flask setups should let Nginx serve /static/ and /media/ directly from stable filesystem paths. If the URL paths, disk paths, and permissions all match, static assets and uploads will load reliably.