How to Set Up Nginx with Docker

How to Set Up Nginx with Docker

How to Set Up Nginx with Docker

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:

Requirements

docker --version
docker compose version

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

Performance Tips

Real-World Use Cases

Best Practices

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.

Exit mobile version