Running Nginx as a reverse proxy in Docker is straightforward, but the best modern workflow is very different from older tutorials that modify a running container and then save it with docker commit.

A better approach is to keep the Nginx configuration in source control, build a reproducible image with a Dockerfile and use Docker Compose to place Nginx and the backend application on the same network.

This tutorial uses:

  • Nginx 1.30.4 stable;
  • Docker Compose;
  • Apache Tomcat 11.0.24 as the backend; and
  • Docker's built-in service discovery instead of hard-coded container IP addresses.

Docker Nginx reverse proxy architecture

The browser connects only to Nginx. Nginx then forwards matching requests to Tomcat over Docker's private application network.

Browser
   |
   | https://localhost/sample/
   v
Nginx container :80
   |
   | https://tomcat:8080/sample/
   v
Tomcat container :8080

The important detail is the hostname tomcat. Docker Compose provides DNS-based service discovery, so Nginx can use the Compose service name instead of an IP address.

Project structure

Create a small project with these files:

docker-nginx-proxy/
├── compose.yaml
└── nginx/
    ├── Dockerfile
    └── default.conf

The Nginx configuration and Dockerfile are now ordinary project files that can be committed to Git, reviewed and rebuilt consistently.

Create the Nginx reverse proxy configuration

Create nginx/default.conf:

server {
    listen 80;
    server_name _;

    location /sample/ {
        proxy_pass https://tomcat:8080/sample/;

        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;
    }
}

This configuration forwards requests under /sample/ to the Tomcat service.

The forwarded headers preserve useful information from the original request. In particular, $proxy_add_x_forwarded_for appends the client address to the forwarding chain instead of discarding an existing one.

Why service names are better than container IP addresses

Do not configure Nginx with a hard-coded address such as 192.168.246.131. Container IP addresses can change when containers are recreated.

Docker Compose creates a default network and registers each service name in Docker's internal DNS. A service named tomcat is therefore reachable at:

https://tomcat:8080

This remains stable even if Docker recreates the Tomcat container with a different IP address.

Create the Nginx Dockerfile

Create nginx/Dockerfile:

FROM nginx:1.30.4

COPY default.conf /etc/nginx/conf.d/default.conf

This image is reproducible. Every build starts with the same Nginx version and copies in the same configuration.

This is preferable to changing a running container and using docker commit, because the Dockerfile documents exactly how the image was produced.

Configure Nginx and Tomcat with Docker Compose

Create compose.yaml in the project root:

services:
  nginx:
    build:
      context: ./nginx
    ports:
      - "80:80"
    depends_on:
      - tomcat

  tomcat:
    image: tomcat:11.0.24-jdk25-temurin
    expose:
      - "8080"

Both services automatically join the same Compose network. Nginx can therefore connect to Tomcat through the hostname tomcat.

Notice that Tomcat uses expose, not ports. The backend does not need to publish port 8080 to the host because only Nginx needs to reach it.

Start the Docker Nginx reverse proxy

From the project directory, build and start the application:

docker compose up -d --build

Check the running services:

docker compose ps

View Nginx logs if necessary:

docker compose logs nginx

Validate the Nginx configuration

Before troubleshooting proxy behavior, verify that Nginx accepts the configuration:

docker compose exec nginx nginx -t

A valid configuration reports that the syntax is OK and the configuration test succeeded.

Reload Nginx without rebuilding the container

During development, if you temporarily mount the configuration file instead of baking it into the image, you can test and reload Nginx directly:

docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload

For a production image, however, rebuild and redeploy when the configuration changes so the running container matches the versioned image definition.

A development-friendly bind mount

During local development, you may prefer to mount default.conf directly so every edit does not require an image rebuild:

services:
  nginx:
    image: nginx:1.30.4
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - tomcat

  tomcat:
    image: tomcat:11.0.24-jdk25-temurin
    expose:
      - "8080"

The :ro suffix mounts the configuration read-only inside the container.

Test the reverse proxy

Assuming a Tomcat application is deployed at the /sample/ context path, test the Nginx endpoint:

curl -I https://localhost/sample/

The request path is:

https://localhost/sample/
          |
          v
Nginx
          |
          v
https://tomcat:8080/sample/

The browser never needs to know Tomcat's container address or internal port.

Understand proxy_pass trailing slashes

Trailing slashes matter in Nginx proxy configuration.

location /sample/ {
    proxy_pass https://tomcat:8080/sample/;
}

With that configuration, a request for:

/sample/orders

is forwarded as:

/sample/orders

If you change either the location or proxy_pass URI, verify the resulting backend path carefully. Nginx's URI replacement rules are precise, and a misplaced slash can produce unexpected routes.

Don't disable proxy buffering by default

Proxy buffering is enabled by default and is useful for most HTTP applications. It allows Nginx to receive the upstream response efficiently while protecting the backend from slow clients.

Disable buffering only for workloads that actually require streaming or very low latency:

location /events/ {
    proxy_pass https://tomcat:8080/events/;
    proxy_buffering off;
}

Add multiple backend services

Docker Compose and Nginx make it easy to route different paths to different containers:

server {
    listen 80;
    server_name _;

    location /java/ {
        proxy_pass https://tomcat:8080/;
    }

    location /api/ {
        proxy_pass https://api:3000/;
    }
}

If the Compose file contains services named tomcat and api, Docker's internal DNS resolves both names automatically.

Use separate frontend and backend networks

For more complex applications, you can explicitly separate network access:

services:
  nginx:
    build: ./nginx
    ports:
      - "80:80"
    networks:
      - frontend

  app:
    image: tomcat:11.0.24-jdk25-temurin
    networks:
      - frontend
      - backend

  db:
    image: postgres:18
    networks:
      - backend

networks:
  frontend:
  backend:
    internal: true

Here, Nginx cannot communicate directly with the database because they do not share a network. The application service is the only component connected to both tiers.

Stop and remove the proxy stack

When finished, shut down the Compose project:

docker compose down

This removes the containers and project network created by Compose.

Modern Docker Nginx reverse proxy workflow

The updated workflow is intentionally simple:

default.conf
     +
Dockerfile
     +
compose.yaml
     |
     v
docker compose up --build
     |
     v
Nginx container
     |
     v
Tomcat service name
     |
     v
backend container

The key improvement is reproducibility. Instead of manually editing a running container and committing its filesystem state, the Nginx configuration, image definition and application topology all live in ordinary project files.

That makes the reverse proxy easier to review, rebuild, test in CI and deploy consistently across environments.