Docker Dev Environment (Part 2): Nginx Reverse Proxy — Dynamic Routing with map
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 withmap-based routingvhosts/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 specificserver_namematch 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:
- Try to serve the exact file (
$uri) — CSS, JS, images - Try to serve the directory (
$uri/) - 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.crtto 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:
- ← Part 1: Architecture
- Part 2: Nginx Reverse Proxy (You are here)
- Part 3: PHP, Databases & Dev Tools →