civil-and-structural-engineering
Deploying Multi-container Web Applications with Docker Compose and Nginx
Table of Contents
Introduction
Modern web applications rarely run as a single monolithic process. Instead, they are composed of multiple collaborating services: a frontend, an API, a database, a cache, a message queue, and perhaps a job runner. Manually managing the lifecycle of each container, their networking, and their dependencies quickly becomes error-prone and time-consuming. Docker Compose and Nginx together provide a battle-tested, production-friendly solution for defining and deploying these multi-container systems with minimal friction. Docker Compose allows you to describe your entire application stack in a single YAML file, while Nginx acts as a reliable reverse proxy and load balancer, routing traffic to the appropriate service. In this expanded guide, we will walk through a realistic setup, dig into configuration details, cover security best practices, and show you how to scale from development to production.
Prerequisites
Before diving in, ensure you have the following installed on your system:
- Docker Engine (version 20.10 or newer) – Install Docker
- Docker Compose (V2 is now integrated into Docker CLI; verify with
docker compose version) – Install Docker Compose - Basic familiarity with terminal commands and YAML syntax.
Optionally have a domain name pointing to your server if you plan to follow the SSL configuration section.
Understanding Docker Compose in Depth
Docker Compose is not just a simple “docker run” wrapper. It is a declarative tool that lets you define services, networks, volumes, environment variables, restart policies, and health checks in a single file called docker-compose.yml. The Compose specification (now part of the Compose Specification) standardises how multi-container applications are described, making your setup portable across different CI/CD pipelines and cloud providers.
Services
Each container image, its ports, volumes, environment, and dependencies are defined under the services key. Services can communicate with each other via a dedicated bridge network that Compose creates automatically. This means you never need to open host ports for inter-service communication — only the reverse proxy needs to expose ports to the outside world.
Networks
By default, Compose creates a single network for all services, giving them DNS-based service discovery using the service name as the hostname. For example, a service named api can be reached by other services simply as http://api:3000. You can also define custom networks for isolation — for instance, putting only the reverse proxy on a public-facing network and keeping database containers on an internal network.
networks:
frontend:
backend:
services:
nginx:
networks:
- frontend
app:
networks:
- frontend
- backend
db:
networks:
- backend
Volumes
Volumes are the preferred mechanism for persisting data generated by Docker containers. In Compose, you can declare named volumes at the top level and mount them into services. This is essential for databases, file uploads, and any stateful service.
volumes:
db_data:
services:
postgres:
image: postgres:16
volumes:
- db_data:/var/lib/postgresql/data
Environment Variables
Externalise configuration using environment variables. You can hardcode them in the Compose file for development, but for production you should use .env files or Docker secrets. Compose automatically loads a .env file located in the same directory if you don't specify --env-file.
services:
api:
image: my-api:latest
env_file:
- ./api.env
environment:
- NODE_ENV=production
Configuring Nginx as a Reverse Proxy – Advanced
Nginx shines as a reverse proxy because of its high performance, low memory footprint, and rich feature set. In a multi-container architecture, Nginx sits at the edge, accepts client requests, and forwards them to the appropriate service based on the request URI, headers, or even body content. It can also handle SSL termination, caching, rate limiting, and load balancing.
Basic Reverse Proxy with Multiple Upstreams
Here is a more complete configuration that demonstrates how to split traffic between two different applications, including handling static assets and WebSockets:
upstream app1_upstream {
server app1:8000;
}
upstream app2_upstream {
server app2:8001;
}
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# App 1 – main web frontend
location / {
proxy_pass http://app1_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# App 2 – admin dashboard
location /admin/ {
proxy_pass http://app2_upstream/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket support (e.g., for live updates)
location /ws/ {
proxy_pass http://app1_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Static assets – serve directly for better performance
location /static/ {
alias /var/www/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
}
Notice the use of upstream blocks. They allow you to define a group of servers for load balancing. Even with a single server, using an upstream group makes it easy to add more replicas later without touching the server block.
SSL/TLS with Let's Encrypt
For production, serving HTTPS is non-negotiable. You can automate certificate renewal using Certbot or acme.sh inside a sidecar container. A common pattern is to mount the certificates as volumes into the Nginx container. For example:
services:
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- ./certs:/etc/nginx/certs:ro
- ./static:/var/www/static:ro
ports:
- "80:80"
- "443:443"
depends_on:
- app1
- app2
For automatic renewal, you can add a companion container like certbot or acme.sh that runs a cron job and reloads Nginx when certificates are refreshed.
Load Balancing and Health Checks
When you scale a service to multiple replicas, Nginx can distribute requests using round-robin, least connections, or IP hash. Combine this with Docker Compose’s replicas option:
upstream api_servers {
least_conn;
server api:80 max_fails=3 fail_timeout=30s;
}
To detect unhealthy containers, Nginx can be configured with health_check (requires NGINX Plus or use the ngx_http_healthcheck_module from the open-source version with a small workaround). A simpler alternative is to rely on Docker’s health checks and only register containers that pass.
Building the Docker Compose File – A Real-World Example
Let’s combine everything into a practical Compose file for a web application consisting of a Node.js API, a React frontend served by Nginx, a PostgreSQL database, and a Redis cache. The Nginx container will serve static files and proxy API requests.
version: "3.9"
services:
nginx:
image: nginx:alpine
container_name: reverse-proxy
restart: unless-stopped
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./frontend/build:/var/www/frontend:ro
- ./certs:/etc/nginx/certs:ro
ports:
- "80:80"
- "443:443"
depends_on:
- api
- frontend
networks:
- public
frontend:
image: my-frontend:latest
# In production, frontend is built into static files and served by Nginx
# This container may run a dev server, but Nginx will bypass it for static files
container_name: frontend-dev
ports:
- "3000:3000"
networks:
- public
# healthcheck: ...
api:
image: my-api:latest
container_name: api-server
restart: unless-stopped
env_file:
- ./api/.env.production
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
networks:
- public
- internal
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4000/health"]
interval: 30s
timeout: 10s
retries: 3
postgres:
image: postgres:16-alpine
container_name: database
restart: unless-stopped
environment:
POSTGRES_USER: app_user
POSTGRES_DB: app_db
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- pgdata:/var/lib/postgresql/data
secrets:
- db_password
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: cache
restart: unless-stopped
volumes:
- redisdata:/data
networks:
- internal
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
volumes:
pgdata:
redisdata:
networks:
public:
internal:
internal: true
secrets:
db_password:
file: ./secrets/db_password.txt
Key points in this file:
- Network isolation: The
internalnetwork is defined asinternal: true, meaning only services connected to it can communicate. The database and cache are not reachable from outside the Docker overlay. - Secrets: Sensitive data like passwords are stored as Docker secrets, mounted as files inside the container. This avoids leaking them in environment variables that can be exposed via
docker inspect. - Health checks: Services declare health checks so that
depends_oncan wait for the conditionservice_healthy. Nginx will start only after the API is healthy. - Static assets: The frontend build output is mounted directly into Nginx, allowing Nginx to serve static files without hitting the frontend dev server. This improves performance and separates concerns.
Deploying the Application – Step by Step
With the Compose file and Nginx configuration ready, deployment involves a few simple commands. First, ensure all configuration files are in place:
project/
├── docker-compose.yml
├── nginx/
│ └── nginx.conf
├── api/
│ └── .env.production
├── frontend/
│ └── build/
├── certs/
│ └── (fullchain.pem, privkey.pem)
└── secrets/
└── db_password.txt
Then run:
docker compose pull # pull latest images
docker compose up -d # start all services in background
docker compose ps # verify services are running
docker compose logs -f # tail logs for all services
Once everything is up, test the application by visiting your domain. Check Nginx logs to confirm requests are being proxied correctly. If you need to update the configuration or environment variables, edit the relevant files and run:
docker compose up -d --no-deps --build nginx # rebuild only nginx if config changed
For zero-downtime deployments, consider using blue-green or rolling updates with Compose’s scale feature and Nginx upstream health checks.
Scaling and Performance Tuning
Docker Compose makes it trivial to scale stateless services like the API:
docker compose up -d --scale api=3
Now three replicas of the API container are running. Nginx, configured with the upstream block, will automatically distribute requests among them. To further enhance performance:
- Enable Gzip compression in Nginx for text-based responses.
- Set proper cache headers for static assets (as shown in the example).
- Use a CDN for global delivery of static files.
- Tune Nginx worker processes to match CPU cores:
worker_processes auto; - Increase connection limits:
worker_connections 1024;
For databases, ensure you have connection pooling in your API and consider using PgBouncer as a sidecar container.
Troubleshooting Common Issues
Even with a well-structured setup, things can go wrong. Here are frequent pitfalls and how to fix them:
- Container cannot reach another service by name: Check that both services are on the same Docker network. Use
docker compose exec service_name ping other_serviceto test. - Nginx returns 502 Bad Gateway: The upstream container may not be running or is not listening on the expected port. Verify with
docker compose logs api. Also ensure the proxy_pass URL includes the correct protocol and port. - Permission errors with volumes: Files mounted into containers inherit the host’s ownership. Use user namespaces or set the
userdirective in Nginx and the service container. - SSL certificate not renewing: If using certbot standalone, ensure ports 80 and 443 are not blocked by Nginx during renewal. Use a webroot method or certbot with docker to avoid conflicts.
- Environment variables not loaded: Verify the
.envfile is in the correct location or use theenv_filedirective properly. You can print env inside the container withdocker compose exec service env.
Security Best Practices
Running multi-container applications in production requires vigilance:
- Never run containers as root unless necessary. Use the
userdirective or specify aUSERin the Dockerfile. - Keep images up to date with regular vulnerability scanning. Use official base images and pin versions.
- Restrict network exposure: Only expose the reverse proxy ports to the internet. All other services should be on internal networks.
- Use secrets management for passwords, API keys, and tokens. Docker secrets are a good start; for larger setups, consider HashiCorp Vault.
- Enable Docker Content Trust to verify image signatures.
- Regularly log and audit container activity using
docker logsor a centralised logging solution like ELK stack. - Apply resource limits in your Compose file to prevent a single container from starving others:
services:
api:
deploy:
resources:
limits:
cpus: '0.50'
memory: 256M
reservations:
cpus: '0.25'
memory: 128M
These limits are respected when using Docker swarm; for plain Compose, they are enforced with docker compose up --compatibility (though it's better to use swarm or K8s for production orchestration).
Conclusion
Docker Compose and Nginx together form a powerful, scalable, and maintainable platform for deploying multi-container web applications. By defining your stack in a single YAML file, you achieve environment parity from development to production. Nginx, as a reverse proxy, provides a central point for SSL termination, traffic routing, load balancing, and caching. We’ve covered the essential components: service definitions, networking, volumes, environment variables, Nginx configuration with security and performance optimisations, scaling tips, and troubleshooting. With this foundation, you are ready to architect, deploy, and operate modern containerised applications with confidence. For further reading, consult the Docker Compose documentation and the Nginx documentation, and explore real-world examples from the likes of Awesome Compose for inspiration.