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
# 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.jpgUse 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.
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:
STATIC_URL=/static/
STATIC_ROOT=/var/www/app/shared/static
MEDIA_URL=/media/
MEDIA_ROOT=/var/www/app/shared/mediaDo not mix:
- source static files inside the repo
- built static output
- user uploads
A common VPS layout:
/var/www/app/
├── current/ # current release
└── shared/
├── static/ # built assets
└── media/ # user uploadsfolder layout showing release directory, shared static directory, shared media directory, and Nginx aliases.
2) Create directories with correct ownership
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/mediaIf 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:
namei -l /var/www/app/shared/media/test.jpg3) Build or collect static files during deploy
Do this during deployment, not at runtime.
Basic example:
rsync -av --delete ./static/ /var/www/app/shared/static/If using a frontend build tool:
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:
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:
sudo nginx -t
sudo systemctl reload nginxImportant 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:
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:
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:
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:
curl -I http://localhost/static/app.css
curl -I http://localhost/media/test.jpgVerify:
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:
df -hCommon causes
- Nginx location block points to the wrong directory.
- Using
rootinstead ofaliasincorrectly 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:
StaticFilesis 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_ROOTduring deploy. - Generated downloads should be written to
MEDIA_ROOTor object storage, not ephemeral container storage.
Common deployment patterns
- VPS without Docker: use
/var/www/app/shared/staticand/var/www/app/shared/mediawith 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
sudo nginx -t
grep -R "location /static\|location /media\|alias\|root" /etc/nginx/sites-enabled /etc/nginx/nginx.confWatch logs during a request
sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/nginx/access.logIf /static/ or /media/ requests show upstream proxy behavior, they may be hitting the app instead of direct file serving.
Test file URLs directly
curl -I http://localhost/static/app.css
curl -I http://localhost/media/test.jpgCheck:
- status code
- content type
- cache headers
- redirect behavior
Confirm files exist
ls -lah /var/www/app/shared/static
ls -lah /var/www/app/shared/media
stat /var/www/app/shared/media/test.jpgCheck parent directory traversal
namei -l /var/www/app/shared/media/test.jpgNginx needs execute permission on parent directories to traverse the path.
Check Docker paths
docker exec -it <container_name> shThen inspect expected locations:
ls -lah /app/staticfiles
ls -lah /app/mediaCompare 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
- Deploy SaaS with Nginx + Gunicorn
- Docker Production Setup for SaaS
- Media Uploads Not Working
- Static Files Not Loading
- SaaS Production Checklist
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.