Docker Dev Environment (Part 2): Nginx Reverse Proxy — Dynamic Routing with map

· 7 min read

In Part 1, we saw the overall architecture. Now let's zoom into the most critical piece: the Nginx reverse proxy — the single gateway that routes every request to the correct container.

The Proxy Dockerfile

FROM nginx:1.20

RUN apt-get update -y
RUN apt-get install -y vim dnsutils net-tools iputils-ping tcpdump

ADD default.conf /etc/nginx/conf.d/default.conf
ADD ./vhosts/ /etc/nginx/conf.d/
ADD ./ssl/ /etc/nginx/
ADD ./ssl/self* /etc/nginx/ssl/
ADD ./nginx.conf /etc/nginx/

The proxy is a standard Nginx image with:

  • Debugging tools (dnsutils, tcpdump) for troubleshooting DNS and network issues
  • default.conf — the main proxy config with map-based routing
  • vhosts/ directory — individual server blocks for PHP projects
  • SSL certificates for HTTPS

Two Routing Strategies

The proxy uses two different strategies depending on the type of backend:

Strategy 1: map + proxy_pass (for tools and HTTP services)

Used for services like Adminer, Mailpit, Ungit — containers that serve HTTP directly.

Strategy 2: vhosts/*.conf + fastcgi_pass (for PHP projects)

Used for Laravel, WordPress, and other PHP applications — where Nginx needs to pass .php files to PHP-FPM via the FastCGI protocol.

Let's explore each one.

Strategy 1: Dynamic Routing with map

The map directive in Nginx creates a lookup table — mapping the $host variable (the domain from the request) to a container name.

default.conf

proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
client_max_body_size 3000M;

# ─── Map: domain → container name ──────────────────
map $host $proxy_target {
    adminer.dev.local                     adminer;
    ungit.dev.local                       ungit;
    mailpit.dev.local                     mailpit;
    pgadmin4.dev.local                    pgadmin4;
    mongo.dev.local                       mongo-express;
    minio.dev.local                       minio;
}

# ─── Map: domain → port ────────────────────────────
map $host $proxy_port {
    default                     80;
    ungit.dev.local             8449;
    mongo.dev.local             8081;
    minio.dev.local             9000;
}

How It Works Step By Step

When a request comes in for http://adminer.dev.local:

1. Nginx reads Host header: "adminer.dev.local"

2. $host = "adminer.dev.local"

3. map $host $proxy_target:
   "adminer.dev.local" → $proxy_target = "adminer"

4. map $host $proxy_port:
   "adminer.dev.local" → not listed → $proxy_port = 80 (default)

5. proxy_pass $scheme://$proxy_target:$proxy_port
   → proxy_pass http://adminer:80

6. Docker DNS resolves "adminer" → 172.18.0.X

7. Request forwarded to container

The Proxy Server Block

server {
    server_name _;                      # Catch-all: any domain
    resolver 127.0.0.11 ipv6=off;      # Docker's internal DNS

    location / {
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_pass $scheme://$proxy_target:$proxy_port;
        proxy_read_timeout 90;
    }
}

Key details:

  • server_name _ — This is a catch-all. It matches any domain that doesn't have a more specific server_name match in the vhosts configs.
  • resolver 127.0.0.11 — This tells Nginx to use Docker's built-in DNS server. Without this, Nginx would try to resolve container names at startup and fail if the container isn't running yet. With the resolver directive, names are resolved at request time.
  • proxy_set_header — These headers ensure the backend knows the original client IP and protocol. Critical for apps that generate URLs or check HTTPS.

Strategy 2: Virtual Hosts for PHP Projects

For PHP projects, we can't use proxy_pass because PHP-FPM speaks FastCGI, not HTTP. Each project gets its own config file in the vhosts/ directory.

Example: blog.conf

server {
    listen 80;
    server_name blog.dev.local;
    root /var/www/html/blog.md/public;

    index index.html index.php;
    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # Pass PHP files to PHP-FPM container
    location ~ \.(php|html)$ {
        fastcgi_pass php84:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

Breaking It Down

root /var/www/html/blog.md/public

This maps to httpdocs/blog.md/public/ on the host machine. Both the proxy and php84 containers mount httpdocs/ at /var/www/html/, so this path exists in both containers.

try_files $uri $uri/ /index.php?$query_string

The classic Laravel/front-controller pattern:

  1. Try to serve the exact file ($uri) — CSS, JS, images
  2. Try to serve the directory ($uri/)
  3. If nothing found, pass to index.php — Laravel's router handles it

fastcgi_pass php84:9000

This is the core connection. Nginx sends PHP execution requests to the php84 container on port 9000 using the FastCGI protocol. Docker DNS resolves php84 to the container's internal IP.

fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name

Tells PHP-FPM which file to execute. $realpath_root resolves symlinks to get the actual path. For a request to /api/users, after try_files rewrites to /index.php, this becomes /var/www/html/blog.md/public/index.php.

Using Different PHP Versions Per Project

This is where the architecture shines. Different vhost configs can point to different PHP containers:

# blog.conf — Modern Laravel app
location ~ \.php$ {
    fastcgi_pass php84:9000;    # PHP 8.4
}

# legacy.conf — Old WordPress site
location ~ \.php$ {
    fastcgi_pass php74:9000;    # PHP 7.4
}

# ancient.conf — Really old project
location ~ \.php$ {
    fastcgi_pass php56:9000;    # PHP 5.6
}

Same proxy, same infrastructure, different PHP versions. No conflicts.

Mixed PHP Versions in One Project

You can even route different URL paths to different PHP versions within the same project:

server {
    server_name abc.dev.local;
    root /var/www/html/abc.com/httpdocs/public;

    # Legacy content section → PHP 7.4
    location /contents {
        try_files $uri $uri/ /contents/index.php?q=$uri&$args;

        location ~ \.(php|html)$ {
            fastcgi_pass php74:9000;
        }
    }

    # Main app → PHP 8.2
    location ~ \.(php|html)$ {
        fastcgi_pass php82:9000;
    }
}

Setting Up Self-Signed SSL

For local HTTPS, we use self-signed certificates.

Generate Certificates

openssl req -x509 -nodes -days 3650 \
  -newkey rsa:2048 \
  -keyout self.key \
  -out self.crt \
  -subj "/C=JP/ST=Tokyo/L=Tokyo/O=Dev/CN=*.dev.local"

Place self.crt and self.key in tools/proxy/ssl/.

HTTPS Server Block

server {
    listen 443 ssl;
    server_name blog.dev.local;
    root /var/www/html/blog.md/public;

    ssl_certificate /etc/nginx/ssl/self.crt;
    ssl_certificate_key /etc/nginx/ssl/self.key;

    index index.html index.php;
    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.(php|html)$ {
        fastcgi_pass php84:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

Tip: Add the self.crt to your OS/browser's trusted certificate store to avoid the "Not Secure" warning.

Adding a New Project: Step by Step

Here's the workflow whenever you add a new project:

1. Clone/create the project

cd httpdocs/
git clone git@github.com:your/project.git my-project

2. Create a vhost config

Copy the template:

cp tools/proxy/vhosts/template.conf.example tools/proxy/vhosts/my-project.conf

Edit it:

server {
    listen 80;
    server_name myproject.dev.local;
    root /var/www/html/my-project/public;   # ← Your project path

    index index.html index.php;
    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.(php|html)$ {
        fastcgi_pass php84:9000;            # ← Choose PHP version
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

3. Add DNS entry

Edit /etc/hosts:

127.0.0.1  myproject.dev.local

4. Rebuild the proxy

make reup n=proxy

5. Done

Visit http://myproject.dev.local in your browser.

Debugging Tips

Check if DNS resolves inside the proxy container

make bash n=proxy
nslookup php84
# → Address: 172.18.0.5

Check Nginx error logs

docker logs proxy --tail 50

Test Nginx config syntax

docker exec proxy nginx -t

Reload Nginx without rebuilding

docker exec proxy nginx -s reload

Nginx Config Loading Order

The proxy's nginx.conf includes configs in this order:

http {
    include /etc/nginx/conf.d/*.conf;       # default.conf (map rules)
    include /etc/nginx/conf.d/*/*.conf;     # Not used in this setup
}

The default.conf contains the catch-all server_name _ blocks. The vhosts/*.conf files are copied directly into /etc/nginx/conf.d/ by the Dockerfile. Nginx uses the most specific server_name match — so server_name blog.dev.local in a vhost file takes priority over server_name _ in default.conf.

What's Next

In Part 3, we'll set up the PHP-FPM containers with extensions, configure multiple MySQL versions, and add dev tools like Mailpit for email testing and Adminer for database management.


Series Navigation:

Comments