
I’m Navdeep. I’ve been deploying Django apps since 2014, back when we ssh’d into bare-metal boxes and ran git pull by hand (yes, really). Last month I migrated a client’s analytics tool to Seenode and hit the greatest hits of prod pain: connection pools melting down, static files mysteriously 404ing, and a DEBUG=True scare at 2 a.m. If you’ve ever whispered “just one last manual migration” before pushing to prod, consider this an intervention.
What follows is the messy, opinionated playbook I’ve been carving out for years—the same advice I end up whiteboarding for mentees after we spend three hours chasing a missing comma in WhiteNoiseMiddleware. Screenshots come straight from my live Seenode project so you can copy the setup without guessing, and I’ve left in the false starts (because that’s what real deployments look like).
Zero-patience TL;DR
- Harden
settings.py, enable conn pooling, and treat env vars like explosives. - Split web/worker/scheduler services; autoscale workers instead of overloading Gunicorn.
- Automate deploys via Seenode’s API so every push ships with guardrails.
Why Production Deployment is Different
The Seenode docs get you live in five minutes, which is perfect for demos. Production? That’s where the sharp edges live:
- Security hardening: Protecting against common vulnerabilities
- Performance optimization: Handling real traffic efficiently
- Reliability: Ensuring your app stays online during deployments
- Monitoring: Knowing what's happening when things go wrong
- Scalability: Preparing for growth from day one
Seenode’s Git-based workflow takes care of the boring bits, but you still have to dial in Django itself. I’m allergic to slow builds or sluggish endpoints—if something feels laggy, I rebuild it. Everything below comes from the live project you’ll see in the screenshots, warts and all.
Production-Ready Settings Configuration
I usually harden settings.py before touching anything else. If that file is sloppy, everything downstream is shaky—security, performance, even observability.
Security First: Environment Variables
# settings.py
import os
import dj_database_url
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY: Never commit SECRET_KEY to version control
# Seenode automatically provides environment variables
SECRET_KEY = os.environ.get('SECRET_KEY')
if not SECRET_KEY:
raise ValueError("SECRET_KEY environment variable is required in production")
# DEBUG: Always False in production
# Using .lower() handles various formats: "false", "False", "FALSE"
DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'
# ALLOWED_HOSTS: Prevents HTTP Host header attacks
# Format in Seenode env vars: "yourdomain.com,www.yourdomain.com,api.yourdomain.com"
ALLOWED_HOSTS = [
host.strip()
for host in os.environ.get('ALLOWED_HOSTS', '').split(',')
if host.strip()
]
# If ALLOWED_HOSTS is empty, add a fallback for Seenode's default domain
if not ALLOWED_HOSTS and not DEBUG:
# You'll want to set this explicitly in production
ALLOWED_HOSTS = ['*'] # Temporary - set your actual domain!
I’ve personally watched a production app fall over because someone left ALLOWED_HOSTS empty. Set it explicitly and fail loudly if it’s missing.
Database Configuration with Connection Pooling
The default dj-database-url configuration works, but production apps need connection pooling to handle concurrent requests efficiently:
# Database configuration optimized for production
DATABASES = {
'default': dj_database_url.config(
conn_max_age=600, # Keep connections alive for 10 minutes
conn_health_checks=True, # Verify connections before use
ssl_require=True, # Force SSL for security
)
}
# Connection pool settings (if using PostgreSQL)
# These prevent "too many connections" errors under load
if 'postgres' in DATABASES['default']['ENGINE']:
DATABASES['default']['OPTIONS'] = {
'connect_timeout': 10,
'options': '-c statement_timeout=30000', # 30 second query timeout
}
On the analytics project I mentioned, median response time dropped from ~340 ms to 180 ms after enabling conn_max_age=600. Not a lab-grade benchmark, but enough proof for the product team to stop blaming PostgreSQL.
Static Files: WhiteNoise Configuration
WhiteNoise is excellent for serving static files, but the default configuration isn't optimized for production:
# Static files configuration
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# WhiteNoise configuration for production
# Add this to MIDDLEWARE (before SecurityMiddleware if present)
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', # Add this
# ... other middleware
]
# WhiteNoise storage with compression
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# Cache static files for 1 year (browsers will cache)
WHITENOISE_MAX_AGE = 31536000 # 1 year in seconds
Pro tip: The CompressedManifestStaticFilesStorage automatically gzips your static files, reducing bandwidth by 60-80%. This is especially important for JavaScript bundles and CSS files.
UPDATE (credit to Priya from our platform team): My earlier draft recommended
ManifestStaticFilesStoragealone. She pointed out it breaks cache busting when you deploy frequently. Swapping toCompressedManifestStaticFilesStoragesolved the stale asset issue instantly, so now it’s the default in every project I touch.
Security Headers and CORS
For APIs or apps with separate frontends, you'll need CORS configuration:
# Install: pip install django-cors-headers
INSTALLED_APPS = [
# ... other apps
'corsheaders',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'corsheaders.middleware.CorsMiddleware', # Should be near the top
# ... other middleware
]
# CORS configuration for production
CORS_ALLOWED_ORIGINS = [
origin.strip()
for origin in os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',')
if origin.strip()
]
# Security headers
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY' # Prevent clickjacking
# If you're using HTTPS (which Seenode provides automatically)
if not DEBUG:
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
Service-to-Service Secrets and Rotation
Environment variables are a good start, but production organizations need stronger guardrails:
- Scoped secrets: Seenode lets you scope env vars per service. Keep worker-only credentials out of the public web container.
-
Rotation playbook: Add
SECRET_KEY_V2, deploy, verify, then removeSECRET_KEY_V1. Document the process so any engineer can rotate in minutes. -
Least privilege databases: Create a separate PostgreSQL user for Celery or analytics jobs with read-only grants, and point
DATABASE_URLto that user from each service. - CI pipelines: If you deploy via GitHub Actions, reference repository secrets and pass them as workflow inputs. Never echo secrets in logs.
This layered approach prevents the all-too-common scenario where someone inadvertently leaks production credentials during debugging or screen sharing.
Personal scar: Last year a teammate dumped
os.environwhile debugging and our DATABASE_URL ended up in plain text in the logs. We spent four hours rotating everything. Now we have this playbook, and I quiz people on it during their first week.
Gunicorn Configuration for Production
The basic Gunicorn command works, but production deployments need proper worker configuration. Create a gunicorn_config.py:
# gunicorn_config.py
import multiprocessing
import os
# Server socket
bind = "0.0.0.0:80" # Seenode uses port 80 by default
backlog = 2048
# Worker processes
# Formula: (2 x CPU cores) + 1
# Seenode provides CPU info in their dashboard
workers = int(os.environ.get('GUNICORN_WORKERS', multiprocessing.cpu_count() * 2 + 1))
worker_class = 'sync' # Use 'gevent' or 'uvicorn.workers.UvicornWorker' for async
worker_connections = 1000
timeout = 30 # 30 seconds - adjust based on your longest request
keepalive = 2
# Logging
accesslog = '-' # Log to stdout (Seenode captures this)
errorlog = '-' # Log to stderr
loglevel = 'info'
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
# Process naming
proc_name = 'django_app'
# Server mechanics
daemon = False
pidfile = None
umask = 0
user = None
group = None
tmp_upload_dir = None
# Graceful timeout for worker restarts
graceful_timeout = 30
Point your Seenode start command at that config:
gunicorn your_project.wsgi:application --config gunicorn_config.py
Too few workers and your app chokes on concurrent requests; too many and you’ll OOM the box. I once “optimized” by setting workers to cpu_count() * 4 and spent the next day chasing memory leaks. More isn’t better—start with (2 * cores) + 1, watch metrics, then adjust.
Advanced Build Script
The basic build.sh works, but here's a production-ready version with error handling:
#!/usr/bin/env bash
set -o errexit # Exit on any error
set -o nounset # Exit on undefined variables
set -o pipefail # Exit on pipe failures
echo "Starting build process..."
# Install dependencies
echo "Installing Python dependencies..."
pip install --upgrade pip
pip install -r requirements.txt
# Run database migrations
echo "Running database migrations..."
python manage.py migrate --no-input
# Collect static files
echo "Collecting static files..."
python manage.py collectstatic --no-input --clear
# Verify critical environment variables
if [ -z "$SECRET_KEY" ]; then
echo "ERROR: SECRET_KEY not set"
exit 1
fi
echo "Build completed successfully!"
Key improvements:
-
--clearflag removes old static files before collecting new ones - Error checking prevents silent failures
- Verbose output helps debug deployment issues
I’m notoriously impatient with repetitive toil. I once spent five hours writing automation just to reclaim a 30-minute weekly deployment chore, and I’d do it again. If a build step slows me down, I script it, commit it, and move on—that mindset is baked into this build.sh.

Successful Seenode build pipeline: dependency install, migrations, collectstatic, and verification.
Environment Variables Setup in Seenode
In the Seenode dashboard, configure these environment variables:
Required:
SECRET_KEY=your-super-secret-key-here-generate-with-openssl-rand-hex-32
DEBUG=False
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
DATABASE_URL=postgresql://user:pass@host:5432/dbname
Recommended for production:
GUNICORN_WORKERS=4
CORS_ALLOWED_ORIGINS=https://yourfrontend.com,https://www.yourfrontend.com
Security tip: Generate a strong SECRET_KEY using:
python -c "import secrets; print(secrets.token_urlsafe(50))"
Triggering Deployments from CI
If you prefer automation over button-clicking (same), wire your pipeline to hit Seenode’s API directly:
curl -X POST https://api.seenode.com/v1/services/$SEENODE_SERVICE_ID/deployments \
-H "Authorization: Bearer $SEENODE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"git_sha": "'"$GITHUB_SHA"'"}'
Drop that into a GitHub Actions step right after tests pass so prod only ships with green builds.
Multi-Service Architecture on Seenode
Most production Django apps rely on more than a single web process. Seenode’s private network makes it easy to compose multiple services without managing Kubernetes.
Web, Worker, and Scheduler Split
Spin up three services from the same repository:
- Web: Runs Gunicorn and exposes port 80.
-
Worker: Runs Celery/RQ with
celery -A your_project worker -l infoand no public port. -
Scheduler: Runs
celery -A your_project beatorpython manage.py crontab run.
All of them share the same managed PostgreSQL database and Redis, but only the web service is accessible publicly.
Background Jobs with Celery on Seenode
# Start command for worker service
celery -A your_project worker --loglevel=info --autoscale=8,2
# Start command for scheduler service
celery -A your_project beat --loglevel=info
Tune autoscale parameters to match Seenode’s CPU allocation, and remember to set CELERY_BROKER_URL/CELERY_RESULT_BACKEND in the worker’s environment variables.
~I tried running the workers inside the web service to “save resources.”~ Don’t do that. Separate services keep crashes isolated and make it easier to scale the noisy neighbors independently.
Read Replicas and Analytics
If analytics queries pile up, provision a second managed PostgreSQL instance as a read replica. Inject READ_REPLICA_URL and configure a router:
DATABASES['replica'] = dj_database_url.parse(
os.environ['READ_REPLICA_URL'],
conn_max_age=600,
ssl_require=True,
)
DATABASE_ROUTERS = ['your_project.db_routers.PrimaryReplicaRouter']
Route heavy read-only workloads to the replica while writes stay on the primary.
Shared Media and CDN Layer
Use django-storages with an S3-compatible provider (Wasabi, Backblaze, or Cloudflare R2). Point MEDIA_URL at a CDN (Cloudflare or Fastly) so static assets never touch your dynos, and configure cache invalidation via webhook when you deploy.
Whenever I onboard a junior developer, this is the architecture diagram we whiteboard first. Once they see how the pieces talk inside Seenode’s private network, everything else—logs, metrics, secret rotation—suddenly clicks. Sharing those “aha!” moments is honestly my favorite part of the job.
Troubleshooting Common Production Issues
Issue 1: 502 Bad Gateway
Symptom: Your app shows "502 Bad Gateway" after deployment.
Cause: Port binding mismatch. Seenode expects your app on port 80, but Gunicorn might be binding to a different port.
Solution:
- Check your Gunicorn bind address:
bind = "0.0.0.0:80" - Verify the Port field in Seenode dashboard is set to
80(not left empty) - Check logs:
gunicornshould show "Listening at: http://0.0.0.0:80"
Issue 2: Database Connection Timeouts
Symptom: Intermittent "OperationalError: could not connect to server" errors.
Cause: Too many database connections or connection pool exhaustion.
Solution:
# In settings.py, add connection limits
DATABASES['default']['CONN_MAX_AGE'] = 600 # Reuse connections
DATABASES['default']['OPTIONS'] = {
'connect_timeout': 10,
}
Also, ensure your worker count doesn't exceed available database connections. If you have 10 workers and each opens 2 connections, you need at least 20 available connections in your PostgreSQL instance.
Issue 3: Static Files Not Loading
Symptom: CSS/JS files return 404 errors.
Cause: Static files weren't collected during build, or WhiteNoise isn't configured correctly.
Solution:
- Verify
collectstaticruns inbuild.sh - Check
STATIC_ROOTpath is correct - Ensure
WhiteNoiseMiddlewareis inMIDDLEWARE(before other middleware that might handle static files) - Check
STATICFILES_STORAGEis set correctly
Issue 4: Memory Issues
Symptom: App crashes or becomes unresponsive under load.
Cause: Too many Gunicorn workers consuming too much memory.
Solution: Calculate optimal worker count based on available memory:
# In gunicorn_config.py
import multiprocessing
import psutil
# Calculate workers based on available memory
# Assume ~100MB per worker (adjust based on your app)
available_memory = psutil.virtual_memory().available / (1024 * 1024) # MB
memory_per_worker = 100 # MB
max_workers_by_memory = int(available_memory / memory_per_worker)
cpu_workers = multiprocessing.cpu_count() * 2 + 1
workers = min(cpu_workers, max_workers_by_memory)
Issue 5: Secrets Logged Accidentally
Symptom: SECRET_KEY, access tokens, or database URLs show up in logs.
Cause: Debug statements (print(os.environ)) or overly verbose log levels.
Solution:
- Add a log filter:
class SensitiveFilter(logging.Filter):
def filter(self, record):
for secret_name in ['SECRET_KEY', 'DATABASE_URL']:
secret_value = os.environ.get(secret_name)
if secret_value:
record.msg = record.msg.replace(secret_value, '[REDACTED]')
return True
- Attach the filter to every handler in
LOGGING. - Rotate the affected credentials immediately if exposure occurred.
Security Checklist for Production
Before going live, verify:
-
DEBUG = False(check environment variable) -
SECRET_KEYis set and strong (50+ characters) -
ALLOWED_HOSTSincludes your domain (no wildcards in production) - Database uses SSL (
ssl_require=True) - CSRF and session cookies are secure (
CSRF_COOKIE_SECURE,SESSION_COOKIE_SECURE) - Security headers are configured (
X_FRAME_OPTIONS,SECURE_CONTENT_TYPE_NOSNIFF) - CORS is configured (if needed) with specific origins, not
* - No sensitive data in logs (filter out passwords, tokens, etc.)
- Database credentials are in environment variables, not code
- Static files are served securely (WhiteNoise handles this)
Performance Optimization Tips
- Enable database query logging in development to find N+1 queries:
if DEBUG:
LOGGING = {
'version': 1,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django.db.backends': {
'level': 'DEBUG',
},
},
}
Use
select_relatedandprefetch_relatedto reduce database queriesEnable database connection pooling (already covered above)
Monitor response times using Seenode's built-in metrics dashboard
Set appropriate cache headers for API responses that don't change frequently
Anything above ~200 ms median latency makes me twitchy, so I keep the Seenode metrics tab and Grafana dashboards pinned. The moment p95 creeps north, we profile queries or drop a cache layer—no “we’ll fix it later” excuses.
Things I Wish Someone Told Me About Seenode + Django
- Port 80 is not implied. You must type it into the service config or you’ll stare at 502s for an hour.
-
collectstatic --clearfeels optional until stale assets haunt you—run it every time. - Connection pooling (
conn_max_age) buys you real latency wins without touching code. - The free tier is perfect for rehearsals, but budget for a paid plan the moment real traffic enters.
- Seenode logs are short-lived; pipe them to your own storage if you debug “the morning after.”
Zero-Downtime Deployments and Rollbacks
Production outages usually stem from rushed deploys. Build a boring release pipeline:
- Pre-flight checks
python manage.py check --deploy
python manage.py test --tag=smoke
Run these in CI and fail the build on any error.
-
Blue/Green rollouts
- Clone the Seenode service (Blue = current, Green = new).
- Deploy the new commit to Green, validate metrics/logs.
- Flip DNS or Seenode routing to Green once satisfied.
-
Instant rollbacks
- Seenode keeps previous container images—hit “Rollback” or redeploy the last known-good Git SHA.
- Keep a
ROLLBACK.mddoc with exact commands, including how to revert migrations (python manage.py migrate app 0010_previous).
-
Feature flags
- Use LaunchDarkly, Flagsmith, or an open-source alternative to gradually expose risky changes without redeploying.
Practice these steps during calm periods so they’re muscle memory during incidents.
Whenever I learn a smoother rollback trick, I hop on a call with my mentees and walk them through it step by step. Half the fun of discovering a better deployment dodge is geeking out about it with people who’ll use it next week.
Next Steps
Now that your Django app is production-ready on Seenode, the next challenge is scaling it. In my next article, I'll cover:
- Architecture patterns for multi-service Django apps
- Database optimization strategies for PostgreSQL on Seenode
- Caching strategies to reduce database load
- Background worker configuration for async tasks
- Performance monitoring and alerting setup
Part 2 drops within the next 24 hours—Git-based scaling deep dive, with workers, read replicas, the works. I’ll link it here as soon as it’s live so you don’t have to hunt for it.
Conclusion
Deploying Django to production on Seenode is straightforward, but production-ready deployments require attention to security, performance, and reliability. The configurations I've shared here are based on real-world deployments and will help you avoid common pitfalls.
If anything here sparks an idea, ping me. I’m the person who can talk for hours about rollout strategies over chai, especially if it means a junior dev can skip the headaches I’ve already collected.
The key takeaway: Don't just make it work—make it work securely, efficiently, and reliably. Your users (and your future self) will thank you.
Ready to deploy? Sign up for Seenode's 7-day free trial and put these strategies into practice. The Git-based workflow makes iterating on these configurations easy—just push to your repository and watch it deploy automatically.
Have questions or run into issues? Drop a comment below, and I'll help you troubleshoot.
Want to learn more about Django deployment? Check out the Seenode Django documentation or explore Seenode's pricing for your production needs.



Top comments (0)