Running Nginx inside Docker is one of the most common patterns I run into, whether it’s serving a static site, acting as a reverse proxy in front of a handful of containerized microservices, or terminating TLS for an app running elsewhere in a Docker Compose stack. In this guide, I’ll cover everything from a bare-bones single container setup to a more realistic multi-service reverse proxy configuration, including volumes, networking, and Let’s Encrypt integration inside containers.
Why Run Nginx in Docker
Containerizing Nginx gives you a few concrete advantages over installing it directly on a host:
- Consistency — the exact same image runs identically on your laptop, CI pipeline, and production servers.
- Isolation — Nginx doesn’t compete for system packages or conflict with other services on the host.
- Easy versioning and rollback — pinning to a specific image tag means you always know exactly what you’re running, and rolling back is just a matter of switching tags.
- Simplified orchestration — it plugs naturally into Docker Compose, Kubernetes, or Swarm setups alongside your application containers.
Requirements
- Docker Engine installed. Verify with:
docker --version
- Docker Compose (bundled with modern Docker Desktop and recent Docker Engine installs as the
docker composeplugin). Verify with:
docker compose version
- Basic familiarity with your application’s structure — you’ll need to know what static files or backend services Nginx should be serving or proxying to.
The Simplest Possible Setup: Serving Static Files
Let’s start basic. Say you have a static site in a folder called site/:
mkdir -p ~/nginx-docker/site
echo "<h1>Hello from Nginx in Docker</h1>" > ~/nginx-docker/site/index.html
You can run the official Nginx image and mount your static files directly, with zero custom image building required:
docker run -d \
--name my-nginx \
-p 8080:80 \
-v ~/nginx-docker/site:/usr/share/nginx/html:ro \
nginx:stable
Visit http://localhost:8080 and you should see your page. The :ro flag mounts the volume read-only inside the container, which is a good habit for content Nginx only needs to read.
Building a Custom Image
For anything beyond a quick test, you’ll want a proper Dockerfile so your configuration and content are baked into a versioned, reproducible image.
nginx-docker/
├── Dockerfile
├── nginx.conf
├── conf.d/
│ └── default.conf
└── site/
└── index.html
Dockerfile:
FROM nginx:stable-alpine
# Remove default config to avoid conflicts
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom configuration
COPY nginx.conf /etc/nginx/nginx.conf
COPY conf.d/ /etc/nginx/conf.d/
# Copy static content
COPY site/ /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
I use the -alpine variant by default — it’s dramatically smaller than the Debian-based image, which speeds up pulls and reduces attack surface, and it works fine for the vast majority of use cases.
conf.d/default.conf:
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location ~* \.(jpg|jpeg|png|css|js|ico)$ {
expires 30d;
add_header Cache-Control "public";
}
}
Build and run it:
docker build -t my-nginx-site .
docker run -d --name my-nginx-site -p 8080:80 my-nginx-site
Using Nginx as a Reverse Proxy for Other Containers
This is where Docker and Nginx really shine together. Say you have a backend API container and a frontend container, and you want Nginx to route traffic to both based on path.
docker-compose.yml:
services:
nginx:
image: nginx:stable-alpine
ports:
- "80:80"
volumes:
- ./conf.d:/etc/nginx/conf.d:ro
depends_on:
- api
- frontend
networks:
- appnet
api:
build: ./api
expose:
- "3000"
networks:
- appnet
frontend:
build: ./frontend
expose:
- "3001"
networks:
- appnet
networks:
appnet:
driver: bridge
conf.d/default.conf:
server {
listen 80;
server_name example.com;
location /api/ {
proxy_pass http://api:3000/;
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;
}
location / {
proxy_pass http://frontend:3001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Notice that I’m using the service names (api, frontend) directly as hostnames in proxy_pass. Docker’s internal DNS resolves these automatically within the same Compose network — no hardcoded IPs required, and it keeps working even if containers restart and get new internal addresses.
Bring it all up:
docker compose up -d --build
Handling Nginx’s DNS Resolution Behavior in Docker
One gotcha that catches people off guard: Nginx resolves upstream hostnames at startup by default, not per-request. If a backend container isn’t up yet when Nginx starts, or if a container gets recreated with a new IP later, Nginx might keep using a stale IP or fail to start cleanly.
The fix is to use Docker’s embedded DNS resolver explicitly with a variable-based proxy_pass, which forces Nginx to re-resolve at request time:
resolver 127.0.0.11 valid=30s;
location /api/ {
set $upstream_api api:3000;
proxy_pass http://$upstream_api/;
}
127.0.0.11 is Docker’s internal DNS server address inside the container network. This pattern is especially important if you’re doing rolling restarts of backend containers without restarting Nginx itself.
Adding HTTPS with Let’s Encrypt in Docker
The cleanest way to handle TLS in a Dockerized Nginx setup is with the nginx-proxy + acme-companion combo, or by running Certbot in a sidecar container. Here’s a straightforward Certbot-based approach:
docker-compose.yml (excerpt):
services:
nginx:
image: nginx:stable-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./conf.d:/etc/nginx/conf.d:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt:ro
networks:
- appnet
certbot:
image: certbot/certbot
volumes:
- ./certbot/www:/var/www/certbot
- ./certbot/conf:/etc/letsencrypt
entrypoint: >
sh -c "trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;"
networks:
- appnet
networks:
appnet:
driver: bridge
Initial certificate issuance (run once, before Nginx expects the certs to exist):
docker compose run --rm certbot certonly \
--webroot -w /var/www/certbot \
-d example.com -d www.example.com \
--email you@example.com --agree-tos --no-eff-email
Your Nginx config then references the mounted certificate paths:
server {
listen 80;
server_name example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://frontend:3001/;
}
}
Testing Your Setup
Check container status and logs:
docker compose ps
docker compose logs -f nginx
Validate the Nginx configuration syntax inside the running container:
docker compose exec nginx nginx -t
Reload configuration without restarting the container (useful after editing mounted config files):
docker compose exec nginx nginx -s reload
Test connectivity:
curl -I http://localhost
curl -I https://example.com
If you’re testing locally without real DNS pointing at your machine, add an entry to your local /etc/hosts file (or C:\Windows\System32\drivers\etc\hosts on Windows):
127.0.0.1 example.com
Troubleshooting Common Issues
“host not found in upstream” errors on startup. This means Nginx tried to resolve a container hostname before that container’s network was ready. Add depends_on in Compose (note it doesn’t wait for the app inside to be ready, just for the container to start), or use the resolver + variable pattern described above so Nginx resolves lazily rather than failing at startup.
Changes to config files don’t take effect. If you’re mounting config as a volume, make sure you’re either restarting the container or running nginx -s reload inside it — copying files into a running container’s filesystem directly (via docker cp) doesn’t automatically trigger a reload.
502 Bad Gateway from Nginx. This almost always means the backend container isn’t listening on the port Nginx expects, or it crashed. Check docker compose logs api (or whatever your backend service is named) and confirm the app is actually listening on 0.0.0.0, not 127.0.0.1 — inside a container, binding only to localhost makes the service invisible to other containers.
Port already in use on the host. If -p 80:80 fails, something else on your host (possibly a non-containerized Nginx or Apache) is already bound to that port. Stop it, or map to a different host port like -p 8080:80.
Permission denied errors on mounted volumes. This is common on Linux hosts with SELinux enabled. Try adding :z or :Z to your volume mount in Compose, or check the file ownership/permissions on the host directory being mounted.
Security Considerations
- Always pin your base image to a specific version tag (
nginx:1.27-alpine) rather thanlatest, so builds are reproducible and you’re not surprised by breaking changes. - Run the container as a non-root user where possible. The official Nginx image already drops privileges for the worker processes internally, but consider
docker scanortrivyto periodically check your image for known CVEs. - Mount configuration and certificate volumes as read-only (
:ro) wherever Nginx doesn’t need to write to them. - Keep your Docker host’s firewall rules tight — only expose the ports you actually need (typically just 80 and 443), and don’t accidentally expose backend service ports directly to the internet alongside Nginx.
- Regularly rebuild your image to pick up security patches in the base Nginx image and Alpine/Debian packages.
Performance Tips
- Use the
-alpineimage variant for a smaller footprint and faster deploys. - Enable gzip or Brotli compression in your Nginx config as usual — Docker doesn’t change any of the underlying Nginx tuning options.
- Set appropriate
worker_processes(oftenauto) andworker_connectionsvalues innginx.conf— container CPU limits set via Docker (--cpus) can affect how many workers make sense. - Use multi-stage Docker builds if you’re bundling a frontend build step (like a React app) so your final Nginx image only contains the compiled static output, not your entire
node_modulesdirectory. - Consider a named volume or bind mount for logs if you need to persist or ship them externally (to something like Loki or an ELK stack) rather than relying on
docker logsalone at scale.
Real-World Use Cases
- Static site hosting: a lightweight, reproducible container serving a Jekyll, Hugo, or plain HTML/CSS site.
- Reverse proxy for microservices: routing
/api,/auth, and/to different backend containers within a Compose stack. - Local development environments: mirroring production routing rules locally so developers test against realistic proxy behavior instead of hitting app servers directly.
- CI/CD pipelines: building and testing your Nginx configuration as part of an automated pipeline before deploying to production.
- Edge TLS termination: a single Nginx container handling HTTPS for an entire internal stack of unencrypted backend services.
Best Practices
- Keep Nginx configuration in version control alongside your
Dockerfileanddocker-compose.yml, never edited ad hoc inside a running container. - Separate concerns: one container for Nginx, separate containers for each backend service, connected via a defined Docker network.
- Use environment-specific config overlays (
docker-compose.override.yml) for local development versus production. - Always run
nginx -tas part of your image build or CI pipeline to catch syntax errors before deployment. - Automate certificate renewal with a dedicated sidecar (like Certbot) rather than manual intervention.
- Document your network topology (which services talk to which) somewhere accessible to your team, since Docker networking can get confusing once you have more than a couple of services.
Once you’ve got Nginx and your application containers working together cleanly, you get a setup that’s portable, reproducible, and genuinely pleasant to maintain — the same stack running on your laptop is the one running in production, which eliminates an entire category of “works on my machine” headaches.