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

Flask Media Files Not Serving (Uploads Broken)

If uploaded files work in development but fail in production, this guide shows you how to restore media file delivery step-by-step. It covers the standard Flask + Gunicorn + Nginx setup, file path validation, URL mapping, and permission fixes.

Quick Fix / Quick Setup

Create a dedicated media directory, set correct permissions, and map /media/ to that directory in Nginx.

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

# Nginx: map URL path to media directory
sudo tee /etc/nginx/sites-available/myapp >/dev/null <<'EOF'
server {
    server_name example.com;

    location /media/ {
        alias /var/www/myapp/media/;
        autoindex off;
    }

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

sudo nginx -t && sudo systemctl reload nginx

Use alias, not root, for the /media/ location. The filesystem path must end with a trailing slash, and uploaded files must actually exist in that directory.

What’s Happening

In production, Gunicorn should not serve user-uploaded media files directly. Nginx should usually serve /media/ URLs from a directory on disk using an alias mapping. If the URL path, filesystem path, or permissions are wrong, uploads appear broken even if the app saved them correctly.

Step-by-Step Guide

  1. Confirm the upload directory used by Flask
    Check your app config for MEDIA_ROOT, UPLOAD_FOLDER, or equivalent.
    python
    UPLOAD_FOLDER = "/var/www/myapp/media"
    
  2. Confirm the media URL prefix used by the app
    Uploaded file links should resolve under a stable prefix such as /media/.
    python
    image_url = f"/media/{filename}"
    
  3. Create the media directory if it does not exist
    bash
    sudo mkdir -p /var/www/myapp/media
    
  4. Place a known file in the media directory for testing
    bash
    sudo cp test.jpg /var/www/myapp/media/
    
  5. Set ownership so the app can write and Nginx can read
    A common setup is to use www-data for both.
    bash
    sudo chown -R www-data:www-data /var/www/myapp/media
    
  6. Set directory and file permissions
    bash
    sudo find /var/www/myapp/media -type d -exec chmod 755 {} \;
    sudo find /var/www/myapp/media -type f -exec chmod 644 {} \;
    
  7. Configure Nginx to serve /media/ from disk
    Add a dedicated location block.
    nginx
    location /media/ {
        alias /var/www/myapp/media/;
        autoindex off;
    }
    
  8. Make sure trailing slashes match
    If the location ends with /, the alias path should also end with /.
    Correct:
    nginx
    location /media/ {
        alias /var/www/myapp/media/;
    }
    
  9. Use a full Nginx server block if needed
    nginx
    server {
        listen 80;
        server_name example.com;
    
        location /media/ {
            alias /var/www/myapp/media/;
            autoindex off;
        }
    
        location / {
            include proxy_params;
            proxy_pass http://unix:/run/gunicorn.sock;
        }
    }
    
  10. Test and reload Nginx
bash
sudo nginx -t
sudo systemctl reload nginx
  1. Request a known file directly

Test a file without going through app templates.

bash
curl -I http://127.0.0.1/media/test.jpg
curl -I https://example.com/media/test.jpg
  1. Verify the app and Nginx point to the same location

If Flask saves to /srv/uploads but Nginx reads /var/www/myapp/media, media delivery will fail. Align both paths.

  1. Check generated URLs

If your app generates absolute URLs, confirm the hostname and scheme are correct. Media links should not point to localhost, an old domain, or an incorrect prefix like /uploads/ if Nginx serves /media/.

  1. If using containers, verify persistent storage

The media directory must be mounted to persistent storage and visible to the process serving files.

bash
docker inspect <container_name> | grep -A 20 Mounts
  1. Remove route conflicts

If Flask uses send_from_directory in production while Nginx is also serving /media/, make sure routes do not conflict. In standard production setups, let Nginx serve media files directly.

  1. Retest with a new upload

Upload a new file, confirm it exists on disk, and request it using the generated /media/... URL.

Common Causes

  • Wrong Nginx directive → Using root instead of alias for /media/ builds the wrong filesystem path → Replace it with:
    nginx
    location /media/ {
        alias /var/www/myapp/media/;
    }
    
  • Wrong directory path → Flask saves uploads to one directory while Nginx reads another → Align UPLOAD_FOLDER and the Nginx alias target.
  • Missing trailing slashlocation /media/ with alias /path/media can resolve paths incorrectly → Use /media/ and /path/media/ together.
  • Permissions problem → Uploads exist but Nginx cannot read them, or Flask cannot write them → Fix owner and mode:
    bash
    sudo chown -R www-data:www-data /var/www/myapp/media
    sudo find /var/www/myapp/media -type d -exec chmod 755 {} \;
    sudo find /var/www/myapp/media -type f -exec chmod 644 {} \;
    
  • Uploads not persisted → Files disappear after restart or deploy, especially with Docker → Mount a persistent volume for media storage.
  • Broken URL generation → Templates reference /uploads/ while Nginx serves /media/ → Standardize the URL prefix.
  • Files never saved → The application failed to write uploads due to validation or write errors → Check Flask and Gunicorn logs.
  • Location block conflict → Another Nginx rule overrides /media/ requests → Inspect the full loaded config with nginx -T.
  • SELinux or AppArmor restrictions → Access is denied despite normal Unix permissions → Adjust policy or move media to an allowed path.
  • CDN or proxy cache issue → Old 404 responses are cached → Purge cache or test directly against origin.

Debugging Section

Check file existence first:

bash
ls -lah /var/www/myapp/media
stat /var/www/myapp/media/test.jpg
namei -l /var/www/myapp/media/test.jpg

Test HTTP responses locally and publicly:

bash
curl -I http://127.0.0.1/media/test.jpg
curl -I https://example.com/media/test.jpg

Validate the active Nginx configuration:

bash
sudo nginx -t
sudo nginx -T | less

Reload after changes:

bash
sudo systemctl reload nginx

Inspect Nginx logs:

bash
sudo journalctl -u nginx -n 100 --no-pager
sudo tail -n 100 /var/log/nginx/error.log
sudo tail -n 100 /var/log/nginx/access.log

Inspect Gunicorn logs if uploads may not be saved correctly:

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

If using Docker, verify mounts:

bash
docker inspect <container_name> | grep -A 20 Mounts

What to look for:

  • 404 in access logs → wrong path mapping or missing file
  • 403 → permissions or security policy issue
  • open() failed in Nginx logs → wrong alias path or unreadable file
  • file missing on disk → Flask upload handling failed or storage is ephemeral
  • requests hitting Flask instead of Nginx media location → route conflict or bad Nginx config

Checklist

  • Uploaded files exist on disk in the expected media directory
  • Flask saves uploads to the same directory Nginx serves
  • Nginx has a location /media/ block with alias, not root
  • The alias path ends with a trailing slash
  • Directory ownership and permissions allow write by app and read by Nginx
  • nginx -t passes without errors
  • Reloading Nginx applies the config successfully
  • A known file opens directly from its /media/ URL
  • New uploads remain available after app restart or deploy
  • If using containers, media storage is persistent and shared correctly

FAQ

Q: Should Gunicorn serve uploaded media files directly?
A: No. In production, Nginx should normally serve uploaded files from disk.

Q: What Nginx directive should be used for /media/?
A: Use alias for a dedicated media URL prefix, not root.

Q: Why do uploads work locally but not on the server?
A: The development server may serve files automatically, but production requires explicit Nginx media mapping and correct permissions.

Q: Why are media files returning 404 after deploy?
A: The files may be stored in the wrong directory, deleted during deploy, or served from a mismatched URL prefix.

Q: How do I confirm the problem is Nginx and not Flask?
A: Check whether the file exists on disk, then request it directly via the /media/ URL and inspect Nginx logs.

Final Takeaway

Most broken Flask media delivery issues come from one of three problems: wrong path, wrong Nginx mapping, or wrong permissions. Validate where the app saves files, map that directory with an Nginx alias, and confirm the files exist and are readable. Once a direct /media/file request works, upload handling in production is usually fixed.