Deployment
#django
#fly-io
#postgresql

Deploy Django on Fly.io with Postgres

A production-safe Fly.io Django deployment needs more than fly launch and fly deploy.

Problem statement

A production-safe Fly.io Django deployment needs more than fly launch and fly deploy. You need Django production settings, a real PostgreSQL connection, secrets stored outside the repo, static files handled correctly, migrations applied in a controlled way, and a rollback plan that accounts for database schema changes.

If you skip those pieces, the app may boot but still fail in production with host header errors, CSRF failures, missing static files, broken HTTPS handling, or a release that cannot be rolled back cleanly after migrations.

Quick answer

To deploy Django on Fly.io with Postgres safely, package the app with Docker, run Django behind Gunicorn, provision a separate Fly Postgres service, attach it to your app, store sensitive settings with fly secrets set, serve static files with WhiteNoise or another production asset strategy, and run manage.py migrate --noinput in Fly’s release phase.

After deploy, verify health checks, logs, HTTPS behavior, static files, and one database-backed request. For rollback, redeploy a previous image only if your schema changes are backward compatible with the older code.

Step-by-step solution

1) Prepare the Django app for Fly.io

Install the core dependencies:

pip install gunicorn psycopg[binary] whitenoise dj-database-url
pip freeze > requirements.txt

Use production settings that read from environment variables:

# project/settings/production.py
import os
from pathlib import Path
import dj_database_url

BASE_DIR = Path(__file__).resolve().parent.parent.parent

DEBUG = False

SECRET_KEY = os.environ["SECRET_KEY"]

ALLOWED_HOSTS = [
    "your-app-name.fly.dev",
    "www.example.com",
    "example.com",
]

CSRF_TRUSTED_ORIGINS = [
    "https://your-app-name.fly.dev",
    "https://www.example.com",
    "https://example.com",
]

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True

SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

# Roll out HSTS only after HTTPS works correctly on every domain.
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = False

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    # ...
]

STORAGES = {
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    }
}

DATABASES = {
    "default": dj_database_url.config(
        env="DATABASE_URL",
        conn_max_age=600,
    )
}

Make sure DJANGO_SETTINGS_MODULE points to the full Python path of your production settings module at deploy time, for example project.settings.production.

Verification check:

python manage.py check --deploy --settings=project.settings.production

Fix every important error before you deploy. Warnings may still be acceptable depending on your app, but do not ignore ALLOWED_HOSTS, HTTPS, secret handling, or proxy-related settings.

Also make sure any custom domains are added to both ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS before cutover.

2) Configure static files for production

Fly Machines have ephemeral local filesystems. That means runtime-generated files stored on local disk are not durable unless you explicitly design around volumes, and that is usually the wrong choice for Django user uploads.

For static assets, WhiteNoise is a practical default for small to medium deployments. It works well when static files are collected during build and served by Django/Gunicorn.

A common problem here is build-time settings import. If your production settings require runtime-only secrets, collectstatic can fail during docker build. Keep static collection independent from production-only values where possible, and do not make collectstatic depend on a live database connection.

If you also need user-uploaded media, use object storage rather than the local filesystem.

3) Create a Dockerfile

Use a Dockerfile that installs dependencies, copies the app, collects static files, and runs Gunicorn:

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV DJANGO_SETTINGS_MODULE=project.settings.production
ENV SECRET_KEY=build-only-not-for-runtime

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN python manage.py collectstatic --noinput

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "project.wsgi:application"]

Replace:

  • project.settings.production with your real settings module path
  • project.wsgi:application with your real WSGI path

Important notes:

  • The build-time SECRET_KEY above is only there so settings can import during image build. Set the real SECRET_KEY later with Fly secrets.
  • If your settings import code touches the database at import time, fix that first. collectstatic should not require a live database connection.

Verification check: build locally first.

docker build -t django-fly-test .

If collectstatic fails during the build, fix that before using Fly.

4) Create the Fly.io app

Authenticate if needed:

fly auth login

Initialize the app:

fly launch

Choose:

  • an app name
  • a region close to your users or database
  • Dockerfile-based deploy if prompted

Do not trust generated defaults blindly. Review fly.toml after launch.

A practical fly.toml looks like this:

app = "your-app-name"
primary_region = "iad"

[http_service]
  internal_port = 8000
  force_https = true
  auto_stop_machines = false
  auto_start_machines = true
  min_machines_running = 1

  [[http_service.checks]]
    interval = "15s"
    timeout = "5s"
    grace_period = "20s"
    method = "GET"
    path = "/healthz"

[deploy]
  release_command = "python manage.py migrate --noinput --settings=project.settings.production"

Important points:

  • internal_port must match Gunicorn’s bind port.
  • Health checks should hit a lightweight endpoint.
  • Migrations belong in release_command, not in ad hoc shell sessions.

Add a minimal health endpoint before deploying:

# project/urls.py
from django.http import HttpResponse
from django.urls import path

def healthz(request):
    return HttpResponse("ok", content_type="text/plain")

urlpatterns = [
    path("healthz", healthz),
]

If your project already has URL patterns, just add the path("healthz", healthz) route.

5) Provision and attach Postgres

Create a separate Fly Postgres service:

fly postgres create

Pick a region close to the Django app when possible. Keep database and app as separate services so compute changes and database lifecycle stay independent.

Attach Postgres to the app:

fly postgres attach <db-app-name>

Then verify that DATABASE_URL is available in the app runtime environment before deploying.

Verification checks:

fly secrets list
fly ssh console

From the SSH console, confirm the variable exists:

printenv DATABASE_URL

Do not paste database credentials into your repo or hardcode them in settings.

If your Postgres endpoint requires SSL, enforce it in Django or through connection parameters in DATABASE_URL. For Fly private Postgres connectivity, verify the required SSL mode for your setup instead of hardcoding it blindly.

Backups and restore

Before risky schema changes, confirm your backup and restore approach. App rollback is easier than schema rollback. Once a migration runs, redeploying an older image only works if the old code is still compatible with the new schema.

For higher-risk changes, use backward-compatible migration patterns first, then remove old code or columns in a later deploy.

6) Configure secrets and environment variables

Set the required Django secrets:

fly secrets set \
  SECRET_KEY='replace-with-a-long-random-value' \
  DJANGO_SETTINGS_MODULE='project.settings.production'

You may also need email, Sentry, payment, or API secrets:

fly secrets set SENTRY_DSN='...' EMAIL_HOST_PASSWORD='...'

Keep local .env files out of version control. Use local environment files only for development, and Fly secrets for production.

Verification check:

fly secrets list

7) Deploy the app

Run the first deploy:

fly deploy

Watch for three things in the output:

  1. image build success
  2. release command success
  3. machine health check success

If migrations fail in release phase, the deploy should not promote successfully. That is what you want. Fix the migration issue before retrying.

Because the release command runs before the new version starts serving traffic, this reduces one common deployment mistake. It does not remove rollback risk if the schema change is not backward compatible.

8) Expose the app securely

Fly terminates TLS at the proxy and forwards the request to your app. Django must trust the forwarded protocol so it knows the original request was HTTPS:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True

Without correct proxy settings, you may see redirect loops, wrong absolute URLs, host validation issues, or CSRF failures.

If using a custom domain, add it in Fly and then update DNS as instructed by Fly:

fly certs add example.com
fly certs add www.example.com

After DNS propagates, verify certificate status:

fly certs show example.com

Then confirm:

  • the domain is present in ALLOWED_HOSTS
  • the HTTPS origin is present in CSRF_TRUSTED_ORIGINS
  • redirects and canonical URLs behave as expected

9) Verify the deployment

Start with Fly status and logs:

fly status
fly logs

Then verify the live app:

curl -I https://your-app-name.fly.dev/
curl -I https://your-app-name.fly.dev/healthz
curl -I https://your-app-name.fly.dev/static/path-to-known-file.css

Test these manually:

  • home page returns 200
  • admin login page loads
  • static assets are served
  • one database-backed page reads and writes correctly
  • HTTPS redirect behavior is correct
  • no debug information is exposed

You can also run:

python manage.py check --deploy --settings=project.settings.production

That command runs locally, but it is still useful as a production settings audit.

Explanation

This setup works because each production concern is handled in the right layer:

  • Fly.io runs the containerized Django app.
  • Gunicorn provides a stable application server.
  • Fly Postgres provides PostgreSQL separately from app compute.
  • Fly secrets keep sensitive configuration out of Git.
  • Release-phase migrations reduce the chance of forgetting schema updates.
  • WhiteNoise handles static files without introducing another service for simpler deployments.

WhiteNoise is a good default when your static assets are modest and you want fewer moving parts. If you expect large static asset volume or need a CDN-heavy setup, move static files to object storage and a CDN. For user-uploaded media, use external object storage instead of the app filesystem.

When manual Fly.io deployment becomes repetitive

Once you repeat this process across environments, the first parts to automate are secret loading, pre-deploy checks, migration gating, and post-deploy smoke tests. A reusable Dockerfile, hardened fly.toml, and deploy script usually remove the most common human errors without changing the underlying architecture.

Edge cases / notes

  • Static files 404: usually means collectstatic did not run, STATIC_ROOT is wrong, or WhiteNoise is missing from middleware.
  • Build fails during collectstatic: your settings likely require runtime-only environment variables or import database-dependent code too early.
  • App crashes on boot: often caused by wrong WSGI module, missing dependency, or internal_port mismatch.
  • Database connection errors: check that Postgres is attached and DATABASE_URL exists in the runtime environment.
  • CSRF failures behind HTTPS: verify CSRF_TRUSTED_ORIGINS includes https://... origins and SECURE_PROXY_SSL_HEADER is set.
  • Wrong absolute URLs or host handling: verify USE_X_FORWARDED_HOST = True and confirm your allowed hosts match the real public domain.
  • Rollback limits: code-only rollback is straightforward; schema rollback is not. Prefer backward-compatible migrations when possible.
  • Media uploads: do not rely on the app filesystem for persistent user media unless you have explicitly designed around Fly volumes and their constraints.
  • Worker count: tune Gunicorn workers to available memory. Do not blindly increase workers on small app instances.
  • Long requests: if you have slow views or large background jobs, move that work out of web requests instead of only raising timeouts.

If you need the broader hardening checklist first, see the Django Deployment Checklist for Production.

For non-platform-specific container patterns, compare this with Deploy Django with Docker and PostgreSQL.

If you want a more traditional VM setup, see Deploy Django with Gunicorn and Nginx on Ubuntu.

Before changing release flow or migrations in production, review How to Roll Back a Django Deployment Safely.

FAQ

Can I deploy Django on Fly.io without Docker?

Fly.io is typically used with containerized deployments. In practice, for Django, Docker is the standard path because it gives you a repeatable build, predictable dependencies, and a clean release process.

Should migrations run during every deploy?

They should run in a controlled release phase when the new release requires schema changes. That is safer than opening a shell and running migrations manually after traffic has already started hitting the new version.

How do I serve static files on Fly.io?

For many Django apps, WhiteNoise is the simplest option. Collect static files during build, store them in STATIC_ROOT, and serve them from the app container. For larger deployments, move static assets to object storage and a CDN.

Can I scale Django and Postgres separately on Fly.io?

Yes. Your Django app and Fly Postgres service are separate. That separation makes production operation easier because app compute and database sizing are managed independently.

What is the safest rollback approach after a failed deploy?

If the failure is code or config only, redeploy the last known good image or release and confirm health checks. If migrations already ran, first confirm the older code is compatible with the current schema. If not, a forward fix is usually safer than a rushed database restore.

2026 · django-deployment.com - Django Deployment knowledge base