A no-fluff deployment runbook for getting a Cookiecutter Django project live on DigitalOcean using Docker and Traefik. Covers the full path from droplet provisioning to a working production deployment with SSL, plus the gotchas I keep hitting.
Pre-flight Checklist
Before touching the droplet, confirm:
- Cookiecutter Django project generated locally with production = Docker, Traefik (or Nginx), Postgres, and your email backend of choice (Mailgun, SendGrid, Anymail, etc.)
- Project pushed to a private GitHub repo
- Domain registered with DNS access
- DigitalOcean account ready
- Local SSH keypair (
~/.ssh/id_ed25519) ready - All
.envs/.production/*files prepared locally (these are git-ignored and must be transferred separately)
Required env files:
.envs/.production/.django
.envs/.production/.postgres
Generate strong values for DJANGO_SECRET_KEY, DJANGO_ADMIN_URL, POSTGRES_PASSWORD, etc:
python -c "import secrets; print(secrets.token_urlsafe(64))"
1. Spin Up the Droplet
- Image: Ubuntu 24.04 (LTS) x64
- Plan: Basic — minimum 2 GB RAM / 1 vCPU. Postgres + Django + Traefik + Redis on 1 GB will OOM during builds.
-
Auth: SSH key (paste your
~/.ssh/id_ed25519.pub) - Region: Closest to your users
-
Hostname: something descriptive (e.g.
myapp-prod-sg1)
Note the public IPv4 once it's provisioned.
2. DNS Records
In your domain registrar (or DigitalOcean DNS):
| Type | Name | Value | TTL |
|---|---|---|---|
| A | @ | <droplet_ip> |
3600 |
| A | www | <droplet_ip> |
3600 |
Traefik will provision Let's Encrypt SSL automatically once DNS resolves and ports 80/443 are open. Wait for DNS to propagate before bringing up the stack — otherwise Let's Encrypt will rate-limit you on failed challenges.
dig yourdomain.com +short
3. Initial Server Hardening
Connect as root
ssh root@<droplet_ip>
Update packages
apt update && apt upgrade -y
Create a non-root user
Replace <username> with your chosen username throughout this guide.
adduser <username>
usermod -aG sudo <username>
Mirror SSH keys to the new user
rsync --archive --chown=<username>:<username> ~/.ssh /home/<username>
Configure UFW
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
Port 80 needs to be open (not just 443) because Traefik uses it for the Let's Encrypt HTTP-01 challenge and to redirect HTTP traffic to HTTPS.
Reconnect as the new user
exit
ssh <username>@<droplet_ip>
Lock down SSH
sudo nano /etc/ssh/sshd_config
Set:
PermitRootLogin no
PasswordAuthentication no
Reload SSH (no full reboot needed):
sudo systemctl reload ssh
Test from a new terminal before closing the current session — if you locked yourself out, the live session is your only way back in.
4. Install Docker & Compose Plugin
# Remove any old versions
sudo apt remove -y docker docker-engine docker.io containerd runc
# Install dependencies
sudo apt install -y ca-certificates curl gnupg lsb-release
# Add Docker's GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Add the repo
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Allow your user to run docker without sudo
sudo usermod -aG docker $USER
newgrp docker
# Verify
docker --version
docker compose version
5. Set Up GitHub Deploy Key
Since the repo is private, the droplet needs SSH access to clone and pull.
ssh-keygen -t ed25519 -C "<username>@yourdomain.com"
# Press enter through prompts (no passphrase, default location)
cat ~/.ssh/id_ed25519.pub
Copy the output and add it as a deploy key on the GitHub repo:
Settings → Deploy keys → Add deploy key
Read-only access is fine unless you're pushing from the server.
Test the connection:
ssh -T git@github.com
6. Clone the Project
cd ~
git clone git@github.com:<your-username>/<your-repo>.git
cd <your-repo>
7. Transfer Production Env Files
The .envs/.production/ folder is git-ignored, so SCP it from local:
From your local machine:
scp -r .envs/.production <username>@<droplet_ip>:~/<your-repo>/.envs/
Verify on the droplet:
ls -la ~/<your-repo>/.envs/.production/
# Should show .django and .postgres
Lock down permissions:
chmod 600 ~/<your-repo>/.envs/.production/.django
chmod 600 ~/<your-repo>/.envs/.production/.postgres
8. Configure Production Domain
Update .envs/.production/.django
nano .envs/.production/.django
Critical variables:
DJANGO_ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
DJANGO_SECURE_SSL_REDIRECT=True
DJANGO_SERVER_EMAIL=noreply@yourdomain.com
DJANGO_DEFAULT_FROM_EMAIL=hello@yourdomain.com
MAILGUN_API_KEY=<key>
MAILGUN_DOMAIN=mg.yourdomain.com
Update compose/production/traefik/traefik.yml
nano compose/production/traefik/traefik.yml
Replace every instance of the placeholder domain with yours. Look for:
- "Host(`example.com`) || Host(`www.example.com`)"
And the Let's Encrypt email:
email: "your_real_email@yourdomain.com"
Use a real, monitored email — Let's Encrypt sends expiry warnings here.
9. Build and Launch
docker compose -f docker-compose.production.yml up --build -d
First build takes 5–10 minutes. Watch logs:
docker compose -f docker-compose.production.yml logs -f
What to look for:
-
traefikshould successfully obtain Let's Encrypt cert (search logs forcertificate obtained) -
djangoshould boot without import errors -
postgresshould be ready and accepting connections
Common first-run failures:
- Cert acquisition fails → DNS hasn't propagated yet, or port 80 is blocked
-
Django can't connect to DB →
.envs/.production/.postgresmismatch -
502 from Traefik → Django container crashed, check
logs django
10. Run Migrations & Create Superuser
# Migrations
docker compose -f docker-compose.production.yml run --rm django python manage.py migrate
# Superuser
docker compose -f docker-compose.production.yml run --rm django python manage.py createsuperuser
# Collect static (usually handled at build time, but run if needed)
docker compose -f docker-compose.production.yml run --rm django python manage.py collectstatic --noinput
Never run
makemigrationson the server. Generate migrations locally, commit them, pull on the server, thenmigrate.
11. Update the Sites Framework
Cookiecutter Django uses Django's Sites framework (especially for django-allauth email links). Update the default site:
docker compose -f docker-compose.production.yml run --rm django python manage.py shell
from django.contrib.sites.models import Site
site = Site.objects.get(pk=1)
site.domain = "yourdomain.com"
site.name = "Your App"
site.save()
exit()
12. Smoke Test
-
https://yourdomain.comloads with valid SSL (no warnings) -
https://yourdomain.com/<DJANGO_ADMIN_URL>/→ admin login works - Sign up flow → confirmation email arrives
- Password reset email arrives
- Static files serving (CSS/JS load, no 404s in DevTools)
-
http://yourdomain.comredirects tohttps://
13. Updating Deployments
For subsequent deploys:
cd ~/<your-repo>
git pull
docker compose -f docker-compose.production.yml up --build -d
docker compose -f docker-compose.production.yml run --rm django python manage.py migrate
If you changed env vars, restart the django service:
docker compose -f docker-compose.production.yml restart django
14. Backups
Cookiecutter Django ships with a backup command for Postgres:
# Create backup
docker compose -f docker-compose.production.yml exec postgres backup
# List backups
docker compose -f docker-compose.production.yml exec postgres backups
# Restore (replace with actual backup filename)
docker compose -f docker-compose.production.yml exec postgres restore backup_2026_05_09T00_00_00.sql.gz
Add a cron job for daily backups + offsite sync to S3 / DO Spaces:
crontab -e
0 3 * * * cd /home/<username>/<your-repo> && docker compose -f docker-compose.production.yml exec -T postgres backup >> /home/<username>/backup.log 2>&1
15. Troubleshooting Cheatsheet
| Symptom | Likely Cause | Fix |
|---|---|---|
| 500 on signup/login | Email backend misconfigured | Check Mailgun/SendGrid keys in .envs/.production/.django
|
| 502 Bad Gateway | Django container down | docker compose ... logs django |
| SSL cert not issued | DNS not propagated, port 80 blocked, or Let's Encrypt rate limit | Wait, check ufw status, check Traefik logs |
| Static files 404 |
collectstatic not run, or whitenoise misconfigured |
Re-run collectstatic, check STATIC_ROOT
|
ALLOWED_HOSTS error |
Domain missing from env | Add to DJANGO_ALLOWED_HOSTS, restart django |
| OOM during build | Droplet too small | Resize to 2GB+ or build images locally and push to registry |
permission denied on docker socket |
User not in docker group | sudo usermod -aG docker $USER && newgrp docker |
16. Next Steps (Optional Hardening)
-
CI/CD: GitHub Actions workflow → SSH into droplet →
git pull && docker compose up --build -d. Use repo secrets for the SSH key. - Monitoring: Sentry (already wired in Cookiecutter Django) + Uptime Robot for external checks.
-
Logs: Ship to a service (Logtail, Papertrail, Datadog) instead of relying on
docker logs. -
Secrets management: Move from
.envfiles to Doppler, Infisical, or DO's encrypted env vars for team workflows. -
Database: Move Postgres off the droplet to DO Managed Postgres once you have real traffic. Update
DATABASE_URLand you're done. - CDN: Serve static/media from DO Spaces + a CDN edge.
Wrapping Up
Your Django app should now be live behind HTTPS, with auto-renewing SSL, a hardened server, and a clear path for future deploys and backups. From here, the obvious next investments are CI/CD, observability, and moving your database to a managed service once traffic justifies it.
Top comments (0)