Static and Media File Handling

The essential playbook for implementing static and media file handling in your SaaS.

This page shows how to handle production static assets and user-uploaded media correctly for a small SaaS running on Nginx, Gunicorn, Docker, or a VPS.

The goal:

  • keep static files separate from media files
  • serve both outside the app process
  • make deploys predictable
  • avoid broken uploads, 404s, and permission issues

Static files are deploy artifacts. Media files are persistent user data. Treat them differently.

Quick Fix / Quick Setup

bash
# Example layout
# /var/www/app/current
# /var/www/app/shared/static
# /var/www/app/shared/media

# 1) Create shared directories
sudo mkdir -p /var/www/app/shared/static /var/www/app/shared/media
sudo chown -R www-data:www-data /var/www/app/shared

# 2) App config example
# Flask / FastAPI settings
STATIC_ROOT=/var/www/app/shared/static
MEDIA_ROOT=/var/www/app/shared/media
MEDIA_URL=/media/
STATIC_URL=/static/

# 3) Copy/build static assets into STATIC_ROOT
rsync -av --delete ./static/ /var/www/app/shared/static/

# 4) Nginx config
server {
    listen 80;
    server_name example.com;

    location /static/ {
        alias /var/www/app/shared/static/;
        access_log off;
        expires 7d;
        add_header Cache-Control "public, max-age=604800";
    }

    location /media/ {
        alias /var/www/app/shared/media/;
        client_max_body_size 20M;
        expires 1h;
        add_header Cache-Control "public, max-age=3600";
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# 5) Validate and reload Nginx
sudo nginx -t && sudo systemctl reload nginx

# 6) Test file access
curl -I http://localhost/static/app.css
curl -I http://localhost/media/test.jpg

Use Nginx or object storage for file serving. Do not serve static or uploaded files through Gunicorn unless you are only testing locally. Keep static files immutable per deploy and media files persistent across deploys.


What’s happening

  • Static files are build artifacts: CSS, JS, compiled frontend assets, logos, fonts.
  • Media files are runtime user uploads: avatars, attachments, generated exports, documents.
  • Static files should be versioned and replaceable on deploy. Media files must survive deploys and restarts.
  • The common production pattern is: app handles business logic, Nginx serves /static/ and /media/ from disk, or media is offloaded to S3-compatible storage.
  • If static or media is routed to the app server by mistake, you get slow responses, 404s, permission errors, or broken uploads.
Browser
Nginx
App

Process Flow

And separate direct file serving paths:

  • Nginx -> /static/
  • Nginx -> /media/

Step-by-step implementation

1) Define explicit paths

Set separate paths for static and media in your app config.

Example environment variables:

bash
STATIC_URL=/static/
STATIC_ROOT=/var/www/app/shared/static
MEDIA_URL=/media/
MEDIA_ROOT=/var/www/app/shared/media

Do not mix:

  • source static files inside the repo
  • built static output
  • user uploads

A common VPS layout:

bash
/var/www/app/
├── current/              # current release
└── shared/
    ├── static/           # built assets
    └── media/            # user uploads

folder layout showing release directory, shared static directory, shared media directory, and Nginx aliases.

📁src/Project root
📁shared/Module

2) Create directories with correct ownership

bash
sudo mkdir -p /var/www/app/shared/static
sudo mkdir -p /var/www/app/shared/media
sudo chown -R www-data:www-data /var/www/app/shared
sudo chmod -R 755 /var/www/app/shared/static
sudo chmod -R 755 /var/www/app/shared/media

If your app runs under a different user, adjust ownership accordingly.

Important:

  • app process must be able to write media
  • Nginx must be able to read static and media
  • parent directories must allow traversal

Check traversal permissions with:

bash
namei -l /var/www/app/shared/media/test.jpg

3) Build or collect static files during deploy

Do this during deployment, not at runtime.

Basic example:

bash
rsync -av --delete ./static/ /var/www/app/shared/static/

If using a frontend build tool:

bash
npm ci
npm run build
rsync -av --delete ./dist/ /var/www/app/shared/static/

If you use framework-specific collection logic, run that in the deploy step and point output to STATIC_ROOT.

Do not write generated static files into:

  • the app container filesystem
  • a temporary working directory
  • a release path that gets replaced on deploy

4) Configure Nginx correctly

Use alias for /static/ and /media/.

Example:

nginx
server {
    listen 80;
    server_name example.com;

    location /static/ {
        alias /var/www/app/shared/static/;
        access_log off;
        expires 7d;
        add_header Cache-Control "public, max-age=604800";
    }

    location /media/ {
        alias /var/www/app/shared/media/;
        client_max_body_size 20M;
        expires 1h;
        add_header Cache-Control "public, max-age=3600";
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Then validate:

bash
sudo nginx -t
sudo systemctl reload nginx

Important Nginx rule:

  • location /static/ { alias /path/to/static/; }

The trailing slash matters. Missing it is a common cause of broken file resolution.

5) Route non-file requests to the app only

Your app server should handle application routes, not file serving for production traffic.

Gunicorn example target:

nginx
location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

If /static/ or /media/ falls through to this block, your routing is wrong.

6) Docker setup

If you use Docker, mount persistent volumes.

Example docker-compose.yml pattern:

yaml
services:
  app:
    image: myapp:latest
    volumes:
      - static_data:/app/staticfiles
      - media_data:/app/media
    environment:
      STATIC_ROOT: /app/staticfiles
      MEDIA_ROOT: /app/media

  nginx:
    image: nginx:stable
    ports:
      - "80:80"
    volumes:
      - static_data:/app/staticfiles:ro
      - media_data:/app/media:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro

volumes:
  static_data:
  media_data:

Matching Nginx config:

nginx
location /static/ {
    alias /app/staticfiles/;
}

location /media/ {
    alias /app/media/;
    client_max_body_size 20M;
}

If files disappear after container restart, you are probably writing to the container filesystem instead of a volume.

7) S3-compatible storage for media

Use object storage when:

  • you run multiple app instances
  • you need durable storage beyond one host
  • backups and scaling are becoming operational overhead

Pattern:

  • app uploads file to S3-compatible storage
  • database stores file key or URL
  • browser retrieves media directly or via signed URL

Do not store user uploads on node-local disk if requests can land on multiple instances without shared storage.

8) Add deploy validation

After each deploy, check:

bash
curl -I http://localhost/static/app.css
curl -I http://localhost/media/test.jpg

Verify:

  • 200 OK
  • expected Content-Type
  • cache headers
  • request is handled by Nginx, not proxied to app

9) Back up media if using local disk

If media stays on local disk, define a backup job.

At minimum:

  • daily backup
  • retention policy
  • restore test
  • monitoring for disk space

Check disk usage:

bash
df -h

Common causes

  • Nginx location block points to the wrong directory.
  • Using root instead of alias incorrectly in Nginx.
  • Missing trailing slash in alias path causing path resolution issues.
  • Static assets were never built or copied during deployment.
  • Media uploads are written to a container or release directory that is replaced on deploy.
  • Incorrect file or directory permissions prevent Nginx from reading files.
  • Application writes uploads to one path while Nginx serves a different path.
  • Upload body size exceeds Nginx or app limits.
  • Reverse proxy sends /static/ or /media/ requests to Gunicorn instead of serving directly.
  • Multiple app instances use local media storage with no shared filesystem or object storage.

Framework-specific notes

  • Flask: Flask can expose a static folder, but production should still let Nginx serve it directly.
  • FastAPI: StaticFiles is fine for local testing, but production should use Nginx for static and media.
  • Vite/Webpack: output compiled assets into a deterministic directory and sync that directory to STATIC_ROOT during deploy.
  • Generated downloads should be written to MEDIA_ROOT or object storage, not ephemeral container storage.

Common deployment patterns

  • VPS without Docker: use /var/www/app/shared/static and /var/www/app/shared/media with Nginx alias blocks.
  • Docker single host: mount persistent volumes and expose them to Nginx or the reverse proxy container.
  • Multiple app instances: local disk media becomes inconsistent unless you use shared storage or object storage.
  • CDN setup: serve static files with immutable hashed filenames and long TTLs. Use shorter cache for media unless media filenames are immutable and versioned.

Debugging tips

Start with Nginx and the filesystem, not the app code.

Validate Nginx config

bash
sudo nginx -t
grep -R "location /static\|location /media\|alias\|root" /etc/nginx/sites-enabled /etc/nginx/nginx.conf

Watch logs during a request

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

If /static/ or /media/ requests show upstream proxy behavior, they may be hitting the app instead of direct file serving.

Test file URLs directly

bash
curl -I http://localhost/static/app.css
curl -I http://localhost/media/test.jpg

Check:

  • status code
  • content type
  • cache headers
  • redirect behavior

Confirm files exist

bash
ls -lah /var/www/app/shared/static
ls -lah /var/www/app/shared/media
stat /var/www/app/shared/media/test.jpg

Check parent directory traversal

bash
namei -l /var/www/app/shared/media/test.jpg

Nginx needs execute permission on parent directories to traverse the path.

Check Docker paths

bash
docker exec -it <container_name> sh

Then inspect expected locations:

bash
ls -lah /app/staticfiles
ls -lah /app/media

Compare app write path vs Nginx read path

This is one of the most common issues.

Example failure case:

  • app writes uploads to /app/uploads
  • Nginx serves /var/www/app/shared/media

Uploads appear to succeed in the app, but file retrieval returns 404.

Fix by aligning app config and Nginx alias.

Watch for ephemeral storage issues

If files disappear after:

  • deploy
  • restart
  • container replacement

then media is likely being written into:

  • a release directory
  • a temp path
  • a non-persistent container filesystem

Checklist

  • Static files are stored separately from media files.
  • Nginx serves /static/ and /media/ directly.
  • Static assets are built or collected during deployment.
  • Media uploads are stored on persistent disk or object storage.
  • Upload size limits are configured in both Nginx and application settings.
  • Cache headers are long for hashed static assets and conservative for media.
  • Permissions allow app writes to media and web server reads to both paths.
  • Deploys do not delete media files.
  • Backups exist for media storage if using local disk.
  • Post-deploy health checks verify a known static and media URL.

For broader release validation, use the full SaaS Production Checklist.


Related guides


FAQ

Should static and media use the same directory?

No. Static files are deployment artifacts and can be replaced safely. Media files are user data and must persist independently.

Can I use local disk for media in an MVP?

Yes, on a single server. Move to S3-compatible storage before running multiple instances or if you need simpler backups and scaling.

Why do uploads succeed but the file URL returns 404?

The app is likely writing to a different path than the one Nginx serves, or Nginx lacks permission to read the file.

What cache policy should I use?

Use long cache TTLs for hashed static assets. Use shorter TTLs for media unless filenames are versioned and immutable.

Should I serve static files through Flask or FastAPI in production?

No. Use Nginx or object storage/CDN for production traffic.

Can I store media inside the app directory?

Avoid it. Deploys, rebuilds, and container replacement can delete it.

When should I move media to S3?

When you run multiple app instances, need stronger persistence, or want simpler backup and scaling.

Why do static files work locally but not in production?

Local development often serves files through the framework directly. Production requires explicit paths, permissions, volume persistence, and web server routing.


Final takeaway

Treat static files as deploy artifacts and media files as persistent user data.

Serve both outside the app process. Keep paths explicit. Verify file access after every deploy.

Most production issues come from:

  • wrong Nginx alias paths
  • missing persistent volumes
  • bad permissions
  • upload size limits
  • writing uploads to ephemeral storage

If you need the deployment baseline first, start with Deploy SaaS with Nginx + Gunicorn or Docker Production Setup for SaaS. If files are already failing, continue with Static Files Not Loading or Media Uploads Not Working.