Nginx Docker Compose: Same File or Separate? Multi-Domain Setup Guide
You cannot run Django on port 80 and Next.js on port 80 at the same time. Nginx solves this with virtual hosts — one entrance, many destinations.
What You Will Build
By the end of this tutorial, you will have a production-grade Nginx reverse proxy running inside Docker Compose that:
- Routes
api.yourdomain.comto your Django API container - Routes
app.yourdomain.comto your Next.js frontend container - Serves static files directly from Nginx (10x faster)
- Handles both containers on the same ports
80and443 - Uses Docker's built-in DNS to find backends automatically
The Big Question: Same Compose File or Separate?
This is the most common architectural decision beginners face. Here is the definitive answer.
Same docker-compose.yml — The Correct Choice for This Scenario
| Factor | Same Compose File | Separate Compose Files |
|---|---|---|
| Networking | Automatic service discovery via Docker DNS (django:8000) | Manual network linking or external bridge networks |
| Deploy Command | docker compose up -d — one command | Run 2–3 separate compose files manually |
| SSL Certificates | One certificate set for all domains | Duplicate cert management across files |
| Static File Sharing | Named volumes shared between services | File copying or complex volume mounts |
| Port Conflicts | Zero — Nginx owns 80/443, backends use internal ports | Risk of port collisions |
| Team Onboarding | One file to understand | Multiple files to trace |
| Best For | Single server, multi-domain, shared infrastructure | Multi-server deployments, independent team ownership |
Verdict: When you are running multiple websites on the same server, sharing ports 80 and 443, with different domains — place Nginx in the same docker-compose.yml. This is the industry standard used by Shopify, Slack, and thousands of production deployments.
When Would You Use Separate Compose Files?
Separate compose files make sense only when:
- Services run on different physical or virtual machines
- Teams own independent microservices with separate release cycles
- You need independent scaling managed by different orchestrators
For a single-server, multi-domain setup, separate files add complexity without benefit.
Architecture Overview
Internet
|
v
Cloudflare / Domain DNS
|
v
Server IP :80 / :443
|
v
+-----------------------------+
| Nginx Container |
| (Ports 80, 443 exposed) |
| |
| api.mydomain.com ------> +-----> Django Container (internal:8000)
| app.mydomain.com ------> +-----> Next.js Container (internal:3000)
| Static files ------>
+-----------------------------+
|
| Docker Bridge Network (app_network)
|
+----+----+
| |
v v
PostgreSQL Redis
(internal) (internal)
Critical insight: Only Nginx exposes ports to the outside world. Django, Next.js, PostgreSQL, and Redis communicate through Docker's private bridge network. This is a zero-trust security pattern — your database is physically unreachable from the internet.
How Docker DNS Makes This Work
When you define services in the same docker-compose.yml, Docker automatically:
- Creates a private bridge network
- Assigns each container an IP address (e.g.,
172.20.0.2) - Runs an embedded DNS server that resolves service names to these IPs
This means Nginx can reach Django using just the service name:
1# Inside Nginx container, "django" resolves to the Django container's IP 2proxy_pass http://django:8000;
You never hardcode an IP address. If the Django container restarts and gets a new IP, Docker DNS updates automatically. This is the foundation of service discovery in containerized environments.
Complete Production Nginx Configuration
Create nginx/conf.d/default.conf:
1# ============================================ 2# UPSTREAM DEFINITIONS 3# These define backend server pools. 4# Docker DNS resolves "django" and "nextjs" automatically. 5# ============================================ 6upstream django_backend { 7 server django:8000; 8 keepalive 32; # Reuse connections, reduces latency 9} 10 11upstream nextjs_frontend { 12 server nextjs:3000; 13 keepalive 32; 14} 15 16# ============================================ 17# HTTP → HTTPS REDIRECT (All Domains) 18# Every request on port 80 is redirected to 443. 19# ============================================ 20server { 21 listen 80; 22 server_name api.mydomain.com app.mydomain.com www.mydomain.com; 23 24 # Allow Let's Encrypt validation before SSL is active 25 location /.well-known/acme-challenge/ { 26 root /var/www/certbot; 27 } 28 29 location / { 30 return 301 https://$host$request_uri; 31 } 32} 33 34# ============================================ 35# DJANGO API — api.mydomain.com 36# ============================================ 37server { 38 listen 443 ssl http2; 39 server_name api.mydomain.com; 40 41 # SSL certificates (mounted from host via volume) 42 ssl_certificate /etc/letsencrypt/live/mydomain.com/fullchain.pem; 43 ssl_certificate_key /etc/letsencrypt/live/mydomain.com/privkey.pem; 44 include /etc/letsencrypt/options-ssl-nginx.conf; 45 ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 46 47 # Security headers — required for production 48 add_header X-Frame-Options "SAMEORIGIN" always; 49 add_header X-Content-Type-Options "nosniff" always; 50 add_header X-XSS-Protection "1; mode=block" always; 51 add_header Referrer-Policy "strict-origin-when-cross-origin" always; 52 add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; 53 54 # Django static files — served directly by Nginx (never hit Django) 55 location /static/ { 56 alias /app/staticfiles/; 57 expires 6M; 58 access_log off; 59 add_header Cache-Control "public, max-age=15552000"; 60 } 61 62 # Django media files — user uploads 63 location /media/ { 64 alias /app/mediafiles/; 65 expires 1M; 66 access_log off; 67 } 68 69 # All other requests → Django Gunicorn 70 location / { 71 proxy_pass http://django_backend; 72 73 # Preserve original request information 74 proxy_set_header Host $host; 75 proxy_set_header X-Real-IP $remote_addr; 76 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 77 proxy_set_header X-Forwarded-Proto $scheme; 78 proxy_set_header X-Forwarded-Host $host; 79 proxy_set_header X-Forwarded-Port $server_port; 80 81 # Timeouts for slow API responses 82 proxy_connect_timeout 60s; 83 proxy_send_timeout 60s; 84 proxy_read_timeout 60s; 85 86 # WebSocket support for Django Channels 87 proxy_http_version 1.1; 88 proxy_set_header Upgrade $http_upgrade; 89 proxy_set_header Connection "upgrade"; 90 } 91} 92 93# ============================================ 94# NEXT.JS FRONTEND — app.mydomain.com 95# ============================================ 96server { 97 listen 443 ssl http2; 98 server_name app.mydomain.com; 99 100 ssl_certificate /etc/letsencrypt/live/mydomain.com/fullchain.pem; 101 ssl_certificate_key /etc/letsencrypt/live/mydomain.com/privkey.pem; 102 include /etc/letsencrypt/options-ssl-nginx.conf; 103 ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 104 105 # Security headers 106 add_header X-Frame-Options "SAMEORIGIN" always; 107 add_header X-Content-Type-Options "nosniff" always; 108 add_header X-XSS-Protection "1; mode=block" always; 109 add_header Referrer-Policy "strict-origin-when-cross-origin" always; 110 add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; 111 112 # Gzip compression for text assets 113 gzip on; 114 gzip_vary on; 115 gzip_min_length 1024; 116 gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; 117 118 # Next.js static files — immutable, cache forever 119 location /_next/static/ { 120 alias /app/.next/static/; 121 expires 1y; 122 access_log off; 123 add_header Cache-Control "public, immutable"; 124 } 125 126 # Public folder assets 127 location /static/ { 128 alias /app/public/; 129 expires 1y; 130 access_log off; 131 add_header Cache-Control "public, max-age=31536000"; 132 } 133 134 # All requests → Next.js Node.js server 135 location / { 136 proxy_pass http://nextjs_frontend; 137 proxy_set_header Host $host; 138 proxy_set_header X-Real-IP $remote_addr; 139 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 140 proxy_set_header X-Forwarded-Proto $scheme; 141 142 proxy_http_version 1.1; 143 proxy_set_header Upgrade $http_upgrade; 144 proxy_set_header Connection "upgrade"; 145 146 proxy_buffering off; 147 proxy_request_buffering off; 148 } 149} 150 151# ============================================ 152# WWW LANDING PAGE — www.mydomain.com 153# ============================================ 154server { 155 listen 443 ssl http2; 156 server_name www.mydomain.com; 157 158 ssl_certificate /etc/letsencrypt/live/mydomain.com/fullchain.pem; 159 ssl_certificate_key /etc/letsencrypt/live/mydomain.com/privkey.pem; 160 161 root /var/www/landing; 162 index index.html; 163 164 location / { 165 try_files $uri $uri/ =404; 166 } 167}
Why These proxy_set_header Directives Matter
Each header solves a specific production problem. Omitting any of them causes subtle, hard-to-debug failures.
| Header | What It Does | What Breaks Without It |
|---|---|---|
Host | Tells Django the original domain requested | Django CSRF validation fails with 403 Forbidden |
X-Real-IP | Passes the client's actual IP address | Your logs show Nginx's IP instead of the visitor's |
X-Forwarded-For | Chain of proxies the request passed through | IP-based rate limiting and analytics break |
X-Forwarded-Proto | Tells Django whether the original request used HTTPS | Django generates http:// URLs causing mixed-content errors |
X-Forwarded-Host | Original host before proxying | Django's build_absolute_uri() returns wrong domains |
X-Forwarded-Port | Original port before proxying | URL generation includes wrong port numbers |
Real-World Example: The CSRF Nightmare
Without proxy_set_header Host $host, Django receives Host: django:8000 instead of Host: api.mydomain.com. Django's CSRF middleware compares the Referer header domain against the Host header. They do not match. Every POST request returns 403 Forbidden. This single missing header has caused hours of debugging for countless developers.
Complete docker-compose.yml with Nginx
This is the same-file setup — Nginx lives alongside all other services in one compose file.
1version: "3.9" 2 3services: 4 # ============================================ 5 # NGINX — The traffic cop. Only service with exposed ports. 6 # ============================================ 7 nginx: 8 image: nginx:1.25-alpine 9 container_name: nginx_proxy 10 restart: unless-stopped 11 ports: 12 - "80:80" 13 - "443:443" 14 volumes: 15 # Nginx configuration (read-only for security) 16 - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro 17 - ./nginx/conf.d:/etc/nginx/conf.d:ro 18 # Static files from Django (read-only) 19 - django_static:/app/staticfiles:ro 20 - django_media:/app/mediafiles:ro 21 # Static files from Next.js (read-only) 22 - nextjs_static:/app/.next/static:ro 23 - nextjs_public:/app/public:ro 24 # SSL certificates 25 - certbot_data:/etc/letsencrypt:ro 26 - certbot_www:/var/www/certbot:ro 27 networks: 28 - frontend 29 depends_on: 30 django: 31 condition: service_healthy 32 nextjs: 33 condition: service_healthy 34 35 # ============================================ 36 # DJANGO API — Internal port 8000, no external exposure 37 # ============================================ 38 django: 39 build: 40 context: ./django 41 dockerfile: Dockerfile.prod 42 container_name: django_api 43 restart: unless-stopped 44 env_file: 45 - ./django/.env.prod 46 volumes: 47 - django_static:/app/staticfiles 48 - django_media:/app/mediafiles 49 networks: 50 - frontend # Nginx can reach this 51 - backend # Can reach DB and cache 52 depends_on: 53 postgres: 54 condition: service_healthy 55 redis: 56 condition: service_healthy 57 healthcheck: 58 test: ["CMD", "curl", "-f", "http://localhost:8000/health/"] 59 interval: 30s 60 timeout: 10s 61 retries: 3 62 start_period: 40s 63 64 # ============================================ 65 # NEXT.JS — Internal port 3000, no external exposure 66 # ============================================ 67 nextjs: 68 build: 69 context: ./nextjs 70 dockerfile: Dockerfile.prod 71 container_name: nextjs_app 72 restart: unless-stopped 73 env_file: 74 - ./nextjs/.env.prod 75 environment: 76 - NODE_ENV=production 77 volumes: 78 - nextjs_static:/app/.next/static 79 - nextjs_public:/app/public 80 networks: 81 - frontend 82 healthcheck: 83 test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"] 84 interval: 30s 85 timeout: 10s 86 retries: 3 87 88 # ============================================ 89 # POSTGRESQL — Internal only, never exposed 90 # ============================================ 91 postgres: 92 image: postgres:16-alpine 93 container_name: postgres_db 94 restart: unless-stopped 95 env_file: 96 - ./django/.env.prod 97 volumes: 98 - postgres_data:/var/lib/postgresql/data 99 networks: 100 - backend 101 healthcheck: 102 test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] 103 interval: 10s 104 timeout: 5s 105 retries: 5 106 107 # ============================================ 108 # REDIS — Internal only, never exposed 109 # ============================================ 110 redis: 111 image: redis:7-alpine 112 container_name: redis_cache 113 restart: unless-stopped 114 volumes: 115 - redis_data:/data 116 networks: 117 - backend 118 healthcheck: 119 test: ["CMD", "redis-cli", "ping"] 120 interval: 10s 121 timeout: 5s 122 retries: 5 123 124# ============================================ 125# NETWORKS: frontend is public-facing, backend is internal-only 126# ============================================ 127networks: 128 frontend: 129 driver: bridge 130 backend: 131 driver: bridge 132 internal: true # CRITICAL: No route to the internet 133 134# ============================================ 135# VOLUMES: Named volumes for persistence and sharing 136# ============================================ 137volumes: 138 postgres_data: 139 redis_data: 140 django_static: 141 django_media: 142 nextjs_static: 143 nextjs_public: 144 certbot_data: 145 certbot_www:
The internal: true Network: Your Security Shield
Notice the backend network has internal: true. This is one of the most important security settings in Docker Compose.
1networks: 2 backend: 3 driver: bridge 4 internal: true
What this means:
- Containers on the
backendnetwork can talk to each other - Containers on the
backendnetwork cannot reach the internet - Containers on the
backendnetwork cannot be reached from the internet - PostgreSQL and Redis live here — they are physically isolated
Even if an attacker compromises your Django container, they cannot scan the internet from it. They can only reach PostgreSQL and Redis. This is defense in depth.
How Static File Sharing Works
Django's collectstatic command gathers all static files into /app/staticfiles/. Nginx needs to serve these directly. They share a named volume:
1# Django service writes to the volume 2volumes: 3 - django_static:/app/staticfiles 4 5# Nginx service reads from the same volume (read-only) 6volumes: 7 - django_static:/app/staticfiles:ro
The flow:
- Django container runs
python manage.py collectstaticduring build or startup - Files are written to
/app/staticfiles/inside thedjango_staticnamed volume - Nginx container mounts the same volume at
/app/staticfiles/ - When a browser requests
/static/logo.png, Nginx serves it directly from disk - Django never receives the request — saving CPU and memory
Performance impact: Serving a static file through Django takes ~50ms. Serving it through Nginx takes ~1ms. At 1000 requests per second, that is the difference between 50 seconds of CPU time and 1 second.
Hands-On Lab: Build a Multi-Domain Proxy Locally
Step 1: Create Project Structure
1mkdir nginx-docker-lab && cd nginx-docker-lab 2mkdir -p nginx/conf.d django nextjs
Step 2: Create a Mock Django App
Create django/app.py:
1from flask import Flask, jsonify 2 3app = Flask(__name__) 4 5@app.route('/') 6def home(): 7 return jsonify({"service": "django-api", "status": "running"}) 8 9@app.route('/health') 10def health(): 11 return jsonify({"status": "healthy"}), 200 12 13if __name__ == '__main__': 14 app.run(host='0.0.0.0', port=8000)
Create django/Dockerfile:
1FROM python:3.12-slim 2WORKDIR /app 3COPY requirements.txt . 4RUN pip install -r requirements.txt 5COPY . . 6CMD ["python", "app.py"]
Create django/requirements.txt:
flask==3.0.3
gunicorn==23.0.0
Step 3: Create a Mock Next.js App
Create nextjs/app.js:
1const http = require('http'); 2 3const server = http.createServer((req, res) => { 4 if (req.url === '/api/health') { 5 res.writeHead(200, { 'Content-Type': 'application/json' }); 6 res.end(JSON.stringify({ status: 'healthy' })); 7 } else { 8 res.writeHead(200, { 'Content-Type': 'application/json' }); 9 res.end(JSON.stringify({ service: 'nextjs-frontend', status: 'running' })); 10 } 11}); 12 13server.listen(3000, '0.0.0.0', () => { 14 console.log('Next.js mock server running on port 3000'); 15});
Create nextjs/Dockerfile:
1FROM node:20-alpine 2WORKDIR /app 3COPY package.json . 4RUN npm install 5COPY . . 6CMD ["node", "app.js"]
Create nextjs/package.json:
1{ 2 "name": "nextjs-mock", 3 "version": "1.0.0", 4 "dependencies": {} 5}
Step 4: Create Nginx Configuration
Create nginx/conf.d/default.conf:
1upstream django_backend { 2 server django:8000; 3} 4 5upstream nextjs_frontend { 6 server nextjs:3000; 7} 8 9server { 10 listen 80; 11 server_name api.localhost; 12 13 location / { 14 proxy_pass http://django_backend; 15 proxy_set_header Host $host; 16 proxy_set_header X-Real-IP $remote_addr; 17 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 18 proxy_set_header X-Forwarded-Proto $scheme; 19 } 20} 21 22server { 23 listen 80; 24 server_name app.localhost; 25 26 location / { 27 proxy_pass http://nextjs_frontend; 28 proxy_set_header Host $host; 29 proxy_set_header X-Real-IP $remote_addr; 30 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 31 proxy_set_header X-Forwarded-Proto $scheme; 32 } 33}
Step 5: Create docker-compose.yml
1version: "3.9" 2 3services: 4 nginx: 5 image: nginx:1.25-alpine 6 ports: 7 - "80:80" 8 volumes: 9 - ./nginx/conf.d:/etc/nginx/conf.d:ro 10 networks: 11 - app_network 12 depends_on: 13 - django 14 - nextjs 15 16 django: 17 build: ./django 18 networks: 19 - app_network 20 healthcheck: 21 test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8000/health"] 22 interval: 10s 23 timeout: 5s 24 retries: 3 25 26 nextjs: 27 build: ./nextjs 28 networks: 29 - app_network 30 healthcheck: 31 test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"] 32 interval: 10s 33 timeout: 5s 34 retries: 3 35 36networks: 37 app_network: 38 driver: bridge
Step 6: Test the Setup
1# Start everything 2docker compose up -d --build 3 4# Verify services are running 5docker compose ps 6 7# Test Django through Nginx (using Host header) 8curl -H "Host: api.localhost" http://localhost/ 9# Expected: {"service": "django-api", "status": "running"} 10 11# Test Next.js through Nginx 12curl -H "Host: app.localhost" http://localhost/ 13# Expected: {"service": "nextjs-frontend", "status": "running"} 14 15# Verify internal DNS works 16docker compose exec nginx ping -c 1 django 17# Expected: PING django (172.x.x.x) 18 19# Check Nginx logs 20docker compose logs nginx 21 22# Clean up 23docker compose down
Common Mistakes and How to Fix Them
Mistake 1: Forgetting proxy_set_header Host
Symptom: Django returns 403 Forbidden on every POST request.
Root Cause: Without proxy_set_header Host $host, Nginx passes Host: django:8000 to Django. Django's CSRF middleware compares the Referer domain (api.mydomain.com) with the Host header (django:8000). They mismatch. CSRF fails.
Fix: Always include all forwarding headers:
1proxy_set_header Host $host; 2proxy_set_header X-Forwarded-Proto $scheme;
Mistake 2: Missing X-Forwarded-Proto
Symptom: Django generates http:// URLs in API responses even though the user accessed https://. Browsers block these as mixed content.
Root Cause: Django does not know the original request used HTTPS.
Fix:
1proxy_set_header X-Forwarded-Proto $scheme;
And in Django's settings.py:
1SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
Mistake 3: No keepalive in Upstream
Symptom: High latency under load, TIME_WAIT socket exhaustion.
Root Cause: Nginx creates a new TCP connection to the backend for every single request.
Fix:
1upstream django_backend { 2 server django:8000; 3 keepalive 32; # Reuse up to 32 connections 4}
Mistake 4: Serving Static Files Through Django
Symptom: Page load times exceed 3 seconds. Django CPU usage is high.
Root Cause: Every CSS, JS, and image request hits Django's WSGI worker instead of being served directly by Nginx.
Fix: Configure Nginx to serve static files:
1location /static/ { 2 alias /app/staticfiles/; 3 expires 6M; 4 access_log off; 5}
Mistake 5: Using localhost in proxy_pass
Symptom: Nginx returns 502 Bad Gateway.
Root Cause: proxy_pass http://localhost:8000 tells Nginx to connect to port 8000 inside its own container. Nginx is not running a server on port 8000 — Django is in a different container.
Fix: Use the service name:
1proxy_pass http://django:8000; # Docker DNS resolves this
Performance Optimization Checklist
| Optimization | Configuration | Expected Impact |
|---|---|---|
| Connection Keepalive | keepalive 32 in upstream | 30-50% latency reduction |
| Gzip Compression | gzip on for text assets | 60-80% bandwidth reduction |
| Static File Caching | expires 1y for immutable assets | Eliminates backend requests |
| HTTP/2 | listen 443 ssl http2 | Multiplexed requests, faster loading |
| Access Log Off | access_log off for static | Reduces disk I/O |
| Client Body Buffer | client_body_buffer_size | Prevents temp file writes |
Best Practices Summary
- Nginx lives in the same
docker-compose.ymlas your apps - Only Nginx exposes ports
80and443 - Backend services (DB, cache) use
internal: truenetworks - All
proxy_set_headerdirectives are present - Static files are served directly by Nginx, not Django
-
keepaliveis configured in upstream blocks - Gzip is enabled for text-based content
- Security headers are set on every response
- Health checks are defined for all services
- SSL certificates are mounted as read-only volumes
What You Learned
| Concept | What It Does |
|---|---|
upstream | Defines a pool of backend servers |
server_name | Routes by domain name (virtual host) |
proxy_pass | Forwards requests to a backend |
proxy_set_header | Preserves original request information |
location | Matches URL patterns |
listen | Binds to a port |
keepalive | Reuses TCP connections |
internal: true | Isolates a network from the internet |
| Named volumes | Shares files between containers |
Next Module
In Module 4: Mini Project — DeployMart v1.0, you will combine everything from Modules 1–3 into a single working application. You will build a Django API, a Next.js frontend, and an Nginx reverse proxy — all running together on your local machine with one command.