Django Static Files in Development and Production: static/ vs staticfiles/
A hands-on guide to Django static/ and staticfiles/, DEBUG modes, WhiteNoise, Docker builds, Fly.io, and when Tigris is—or is not—needed.
Django Static Files in Development and Production: static/ vs staticfiles/
One of the easiest Django deployment mistakes is treating static/ and staticfiles/ as duplicate folders. They are not. One is source code. The other is a generated deployment artifact.
The distinction becomes important when DEBUG changes from True on a laptop to False in production. Django serves assets differently in those two modes, and a missing production manifest can turn a healthy application into a site-wide 500 error.
This guide shows how to set up the two folders correctly with Django, WhiteNoise, Docker, and Fly.io. It also explains where object storage such as Tigris fits—and where it does not.
The two folders
Use this mental model:
static/ = source assets developers edit
staticfiles/ = collected assets Django deploys
In a typical project:
project/
├── static/
│ ├── css/site.css
│ └── assets/devblog/favicon.ico
└── staticfiles/
├── css/site.abc123.css
├── assets/devblog/favicon.ico
└── staticfiles.json
static/ belongs in version control. staticfiles/ is generated by collectstatic and should normally be ignored by Git. Never edit a file in staticfiles/ by hand; change the source file and collect again.
Configure the source and output directories
In config/settings.py, tell Django where source assets live and where the collected production output should go:
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
STATICFILES_DIRS is an input. STATIC_ROOT is an output. Django also searches static/ directories inside installed apps, so an app can ship its own assets alongside the project-level directory.
Load files through Django’s static template tag rather than hard-coding paths:
{% load static %}
<link rel="stylesheet" href="{% static 'css/site.css' %}">
<img src="{% static 'assets/devblog/favicon.ico' %}" alt="">
This matters in production because the final URL may contain a content hash.
What happens with DEBUG=True
During local development, Django’s runserver can serve files directly from the source directories. The normal workflow is:
export DJANGO_DEBUG=True
export DJANGO_DB_ENGINE=postgres
uv run --python .venv/bin/python manage.py runserver
When you edit static/css/site.css, the development server can serve the updated source file without a production collection step. This is convenient, but it is not a production serving strategy.
Development behavior is optimized for fast feedback, not caching, compression, or immutable deployment artifacts.
What happens with DEBUG=False
Production should collect files before the application starts:
DJANGO_DEBUG=False \
DATABASE_URL=postgresql://build:build@localhost:5432/build \
python manage.py collectstatic --noinput
The result is written to STATIC_ROOT, which is /app/staticfiles inside this project’s container. Django may compress files and add hashes such as site.abc123.css. The manifest records the mapping between the source name and the generated name.
Configure WhiteNoise storage for production:
STATICFILES_STORAGE = (
'django.contrib.staticfiles.storage.StaticFilesStorage'
if DEBUG or 'test' in sys.argv
else 'whitenoise.storage.CompressedManifestStaticFilesStorage'
)
STORAGES = {
'staticfiles': {
'BACKEND': STATICFILES_STORAGE,
},
}
Add WhiteNoise early in middleware:
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
# ...
]
With DEBUG=False, the browser receives the collected file from /app/staticfiles, not directly from the source directory.
The Docker build must use production settings
A subtle but important detail: the Docker build environment is separate from the runtime environment. If the Dockerfile runs collectstatic while DEBUG defaults to True, Django uses the development storage backend while building the image. At runtime, DEBUG=False then asks for a manifest that was never generated.
Make the build mode explicit:
RUN DJANGO_DEBUG=False DATABASE_URL=postgresql://build:build@localhost:5432/build \
python manage.py collectstatic --noinput
The database URL here is only a build-time placeholder. collectstatic should not need to query the production database. The real DATABASE_URL is provided to the running Fly machine through its environment or secrets.
The complete asset pipeline is:
assets/input.css
↓ npm run build:css
static/css/site.css
↓ Django collectstatic with DEBUG=False
/app/staticfiles/ + staticfiles.json
↓ WhiteNoise
browser receives compressed, hashed asset
Fly.io configuration
For a Fly deployment, keep the production setting in fly.toml:
[env]
DJANGO_DEBUG = 'False'
If a Fly secret with the same name exists, it overrides the value in fly.toml. Check the secret names without printing their values:
fly secrets list --app your-django-app
If DJANGO_DEBUG is present as a secret, set it deliberately:
fly secrets set DJANGO_DEBUG=False --app your-django-app
After changing the Dockerfile or fly.toml, deploy a new image. A runtime secret change restarts the machines but does not rebuild the Docker image, so it cannot repair a missing staticfiles.json by itself.
Is Tigris required?
No. Tigris is object storage; it is useful for persistent uploads and media such as user photos, exported reports, or attachments. It is not required for immutable CSS, JavaScript, or favicon files that are packaged with the application image.
Bundling static files into the image has useful properties:
- Every machine serves the same asset version as the application code.
- Deployments are self-contained and repeatable.
- WhiteNoise can serve assets without a separate storage request.
- Static assets do not depend on a persistent Fly volume.
Use Tigris or another object store when files are created or changed at runtime and must survive a new deployment. Use static/ plus collectstatic when files are part of the application release.
Troubleshooting checklist
If the whole site returns 500 after setting DEBUG=False, check the following:
- Confirm the runtime value:
bash
fly secrets list --app your-django-app
fly config show --app your-django-app
- Confirm the Docker build runs
collectstaticwithDJANGO_DEBUG=False. - Confirm
STATIC_ROOTis/app/staticfilesinside the image. - Confirm
staticfiles.jsonexists after collection. - Confirm
WhiteNoiseMiddlewareis installed before application middleware. - Read Fly logs for
Missing staticfiles manifest entryor template errors. - Run Django’s system checks with production settings:
bash
DJANGO_DEBUG=False python manage.py check
The key rule is simple: edit static/, collect into staticfiles/, and make sure collection uses the same production storage mode that the running application uses.