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:8000or a Unix socket - put Caddy in front as the only public entry point on ports
80and443 - let Caddy issue and renew TLS certificates automatically
- configure Django for reverse proxy HTTPS with
SECURE_PROXY_SSL_HEADERand 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/mediaif you keep uploads on local disk - Gunicorn bound to
127.0.0.1:8000 - Caddy listening publicly on
80and443 - PostgreSQL and Redis running separately if used
systemdmanaging 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
DEBUGis offALLOWED_HOSTSincludes the real domainCSRF_TRUSTED_ORIGINSuseshttps://...STATIC_ROOTpoints to the same path your proxy setup expectscollectstaticcompletes 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.compoints to the server - ports
80and443are 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:
collectstaticmust write to the sameSTATIC_ROOTCaddy serves- keep static files outside the individual release directory if you want safer rollbacks
- make sure the Caddy user can read the
STATIC_ROOTdirectory 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 = TrueCSRF_COOKIE_SECURE = TrueCSRF_TRUSTED_ORIGINSincludes your HTTPS domainALLOWED_HOSTSincludes 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 optionally22/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
currentsymlink instead of deploying withgit pullin 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 injournalctl -u caddy.
Internal links
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
80or443are 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.