Deployment
#django
#caddy
#gunicorn

Deploy Django with Caddy and Automatic HTTPS

If you want to deploy Django with Caddy in production, the main challenge is not just putting a reverse proxy in front of the app.

Problem statement

If you want to deploy Django with Caddy in production, the main challenge is not just putting a reverse proxy in front of the app. You need a complete path that covers app process management, automatic HTTPS, Django proxy settings, static files, migrations, service supervision, and a safe rollback path.

A partial setup often fails in predictable ways: Gunicorn is exposed publicly, HTTPS works but Django still thinks requests are insecure, CSRF breaks on form POSTs, static files return 404, or a bad config reload takes the site down. The goal is to run Django behind Caddy safely so TLS is automatic, the app only listens locally, and updates are repeatable.

Quick answer

A reliable Django Caddy deployment looks like this:

  • run Django with Gunicorn on 127.0.0.1:8000 or a Unix socket
  • put Caddy in front as the only public entry point on ports 80 and 443
  • let Caddy issue and renew TLS certificates automatically
  • configure Django for reverse proxy HTTPS with SECURE_PROXY_SSL_HEADER and correct hosts
  • collect static files to a stable path and either let Caddy serve them from disk or move them to object storage
  • manage Gunicorn with systemd
  • validate Caddy config before reload and verify HTTPS, redirects, CSRF, and static assets after deploy

Step-by-step solution

1. Architecture for deploying Django with Caddy

A practical production layout looks like this:

  • app code release at /srv/myapp/current
  • Python virtualenv at /srv/myapp/venv
  • shared static files at /srv/myapp/shared/static
  • shared media files at /srv/myapp/shared/media if you keep uploads on local disk
  • Gunicorn bound to 127.0.0.1:8000
  • Caddy listening publicly on 80 and 443
  • PostgreSQL and Redis running separately if used
  • systemd managing Gunicorn and Caddy

Why Caddy fits this setup:

  • automatic HTTPS by default
  • simple reverse proxy configuration
  • built-in HTTP to HTTPS redirects
  • less TLS maintenance than manual certificate management

Deployment invariant: Caddy should be the only public entry point. Gunicorn should never listen on a public interface.

2. Prepare the Django app for production

Update Django settings before exposing the app.

DEBUG = False

ALLOWED_HOSTS = ["example.com", "www.example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

STATIC_URL = "/static/"
STATIC_ROOT = "/srv/myapp/shared/static"

MEDIA_URL = "/media/"
MEDIA_ROOT = "/srv/myapp/shared/media"

If you want Django itself to enforce HTTPS redirects, you can add:

SECURE_SSL_REDIRECT = True

That is optional when Caddy already redirects HTTP to HTTPS, but it adds protection if traffic reaches Django through an unexpected internal path.

Store secrets outside the repository. A simple systemd environment file works well:

sudo mkdir -p /etc/myapp
sudo nano /etc/myapp/myapp.env

Example:

DJANGO_SECRET_KEY=replace-this
DJANGO_SETTINGS_MODULE=config.settings.production
DATABASE_URL=postgres://myuser:mypassword@127.0.0.1:5432/myapp
ALLOWED_HOSTS=example.com,www.example.com

Protect it:

sudo chown root:root /etc/myapp/myapp.env
sudo chmod 600 /etc/myapp/myapp.env

Make sure your Django settings actually read environment variables if you use them in the env file.

Run migrations and collect static during deployment, not manually in the middle of troubleshooting:

cd /srv/myapp/current
source /srv/myapp/venv/bin/activate
python manage.py migrate
python manage.py collectstatic --noinput

Verification

  • DEBUG is off
  • ALLOWED_HOSTS includes the real domain
  • CSRF_TRUSTED_ORIGINS uses https://...
  • STATIC_ROOT points to the same path your proxy setup expects
  • collectstatic completes without storage errors

Rollback note

If migrations are risky, take a database backup first. Code rollback is usually easier than schema rollback.

3. Install and configure Gunicorn

Create the virtualenv and install dependencies:

sudo mkdir -p /srv/myapp
sudo chown -R deploy:deploy /srv/myapp

cd /srv/myapp
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r /srv/myapp/current/requirements.txt
pip install gunicorn

Test Gunicorn manually before adding Caddy:

cd /srv/myapp/current
source /srv/myapp/venv/bin/activate
gunicorn --bind 127.0.0.1:8000 config.wsgi:application

In another shell on the server:

curl -I http://127.0.0.1:8000/

You should get 200 or 302, depending on your app.

Create a systemd service:

sudo nano /etc/systemd/system/gunicorn.service
[Unit]
Description=Gunicorn for myapp
After=network.target

[Service]
User=deploy
Group=www-data
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/srv/myapp/venv/bin/gunicorn \
    --workers 3 \
    --bind 127.0.0.1:8000 \
    --access-logfile - \
    --error-logfile - \
    config.wsgi:application
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Load and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn
sudo systemctl status gunicorn

Check logs if needed:

journalctl -u gunicorn -n 50 --no-pager

Verification

  • Gunicorn is active in systemctl status
  • curl http://127.0.0.1:8000/ works locally
  • Gunicorn is not listening on a public interface

Check listening ports:

ss -tulpn | grep 8000

You want 127.0.0.1:8000, not 0.0.0.0:8000.

4. Install Caddy and configure automatic HTTPS

Install Caddy using your distro package source. On Debian or Ubuntu, if Caddy is already available from your configured repositories:

sudo apt update
sudo apt install -y caddy
sudo systemctl enable --now caddy
sudo systemctl status caddy

Create the Caddyfile:

sudo nano /etc/caddy/Caddyfile

Minimal reverse proxy setup:

example.com, www.example.com {
    encode gzip zstd

    header {
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
    }

    reverse_proxy 127.0.0.1:8000
}

If you want Caddy to serve collected static files directly from disk:

example.com, www.example.com {
    encode gzip zstd

    header {
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
    }

    handle /static/* {
        root * /srv/myapp/shared
        file_server
    }

    reverse_proxy 127.0.0.1:8000
}

With this layout, requests for /static/... map correctly to files under /srv/myapp/shared/static/....

Validate before reload:

sudo caddy validate --config /etc/caddy/Caddyfile

Reload safely:

sudo systemctl reload caddy

Inspect logs if certificate issuance fails:

journalctl -u caddy -n 100 --no-pager

Verification

  • DNS for example.com points to the server
  • ports 80 and 443 are reachable externally
  • HTTP redirects to HTTPS:
curl -I http://example.com
  • HTTPS responds successfully:
curl -I https://example.com

Expected result: an HTTP redirect on port 80, then 200 or 302 on HTTPS.

Rollback note

If a Caddy config change breaks the site, restore the previous /etc/caddy/Caddyfile, validate it, then reload Caddy. You do not need to restart Gunicorn for a proxy-only issue.

5. Serve static and media files correctly

For static files, common options are:

  • Caddy serves collected static files from disk: simple for single-server deployments
  • Object storage/CDN: better when you want shared or scalable asset delivery

If using local static files:

  • collectstatic must write to the same STATIC_ROOT Caddy serves
  • keep static files outside the individual release directory if you want safer rollbacks
  • make sure the Caddy user can read the STATIC_ROOT directory and files

Media files need more caution. Local disk works for small deployments, but it complicates backups, multi-server scaling, and disaster recovery. If user uploads matter, plan backup and restore explicitly or move media to object storage.

6. Security checks for Django behind Caddy

Important Django settings behind a reverse proxy:

  • SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
  • SESSION_COOKIE_SECURE = True
  • CSRF_COOKIE_SECURE = True
  • CSRF_TRUSTED_ORIGINS includes your HTTPS domain
  • ALLOWED_HOSTS includes only your expected hostnames

Important Caddy and network checks:

  • Caddy is the only public entry point
  • Gunicorn binds only to localhost or a Unix socket
  • do not expose a direct external path to Gunicorn
  • firewall allows only 80/tcp, 443/tcp, and optionally 22/tcp

For example with UFW:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 22/tcp
sudo ufw status

7. Release workflow for updates

A practical deploy sequence for an in-place setup:

cd /srv/myapp/current
source /srv/myapp/venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py collectstatic --noinput
sudo systemctl restart gunicorn

Reload Caddy only if the proxy config changed:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Low-risk pattern:

  • verify locally through Gunicorn first if possible
  • restart Gunicorn after code, dependency, or migration changes
  • validate Caddy before every proxy reload
  • if you want reliable rollbacks, use release directories with a current symlink instead of deploying with git pull in place

When to script this

Once you repeat this deployment more than a few times, the manual steps become error-prone. Good early automation targets are the environment file install, Gunicorn unit setup, migrate plus collectstatic, Caddy validation, and post-deploy health checks. A reusable template also helps keep Caddy, Gunicorn, and Django settings consistent across projects.

8. Verify the deployment

Application checks:

  • homepage loads over HTTPS
  • admin login works
  • forms submit without CSRF errors
  • static assets load with 200
  • redirects do not loop

Service checks:

systemctl status gunicorn
systemctl status caddy
journalctl -u gunicorn -n 50 --no-pager
journalctl -u caddy -n 50 --no-pager
caddy validate --config /etc/caddy/Caddyfile

Header and redirect check:

curl -I http://example.com
curl -I https://example.com

Port exposure check:

ss -tulpn | grep -E '(:80|:443|:8000)'

You want 80 and 443 public, and 8000 local only.

Explanation

This setup works because it separates concerns cleanly:

  • Gunicorn runs the Django WSGI application
  • Caddy handles client connections, TLS, redirects, and reverse proxying
  • systemd keeps services running and restarts them if they fail

Caddy is a good choice when you want automatic HTTPS with minimal reverse proxy configuration. Compared with more manual setups, it reduces certificate management overhead and usually gives a faster path to a correct HTTPS deployment.

Using a stable shared path for static files avoids tying assets to one code release. That matters if you want cleaner rollbacks with release directories and a current symlink.

You can use a Unix socket instead of 127.0.0.1:8000 if you want a slightly tighter local-only boundary, but localhost TCP is often simpler to debug. If you run multiple Django sites on one server, Caddy can route each domain to a different Gunicorn service with separate site blocks. If you use Django Channels, Caddy can proxy WebSocket traffic too, but you will need an ASGI server and a matching app server design.

Be careful with Let’s Encrypt rate limits during repeated testing. Use correct DNS, open ports 80/443, and avoid repeatedly reloading broken configs on a production hostname.

Edge cases / notes

  • Unix socket instead of TCP: supported and often cleaner, but requires matching socket permissions between Caddy and Gunicorn.
  • Multiple sites on one server: give each app its own systemd service, working directory, environment file, and Caddy site block.
  • Static files: local disk is fine for one server; object storage is usually better for shared or scalable deployments.
  • Media files: avoid local-only storage if uploads are important and recovery matters.
  • 502 errors after deploy: usually Gunicorn failed to start, the bind target changed, or Caddy points at the wrong upstream.
  • Migration failures: restore from backup if needed; not every schema change is safely reversible.
  • HTTPS issues: usually DNS mismatch, blocked 80/443, or certificate issuance errors visible in journalctl -u caddy.

For the Django-side hardening checklist, see Django Deployment Checklist for Production.

If you want a comparable reverse proxy stack, see Deploy Django with Gunicorn and Nginx on Ubuntu.

For a Docker-based production workflow, see Deploy Django with Docker Compose in Production.

If the proxy is up but requests fail, see Deploy Django ASGI with Uvicorn and Nginx for an ASGI alternative and Fix Django 502 Bad Gateway in production if available in your troubleshooting section.

FAQ

Do I still need Gunicorn if I deploy Django with Caddy?

Yes. Caddy is the reverse proxy and TLS terminator. Django still needs an application server such as Gunicorn to run the Python app.

How do I configure Django so HTTPS works correctly behind Caddy?

Set:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ["https://example.com"]

Also make sure ALLOWED_HOSTS includes the production domain, and keep Gunicorn reachable only from localhost or a Unix socket.

Should Caddy serve Django static files or should I use object storage?

For a single server, Caddy serving collected static files from disk is simple and works well. For multi-server deployments or heavier traffic, object storage is usually easier to scale and recover.

Why is Caddy not issuing a certificate for my Django site?

Usually one of these is wrong:

  • domain DNS does not point to the server
  • ports 80 or 443 are blocked
  • the server is behind another proxy that interferes with validation
  • the hostname in the Caddyfile does not match the public domain

Check:

journalctl -u caddy -n 100 --no-pager

How do I roll back safely if a Django deploy behind Caddy fails?

If the problem is app code, restore the previous release and restart Gunicorn. If the problem is only proxy config, restore the previous Caddyfile, validate it, and reload Caddy without changing the app service. For risky migrations, take a database backup before deployment.

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