DNS
│
*.local.dev-gutools.co.uk
│
▼
Developer machine
:80 / :443
│
▼
┌──────────────────────────┐
│ devcontainer-nginx │
│ - Nginx TLS terminator │
│ - Go discovery service │
│ - route generator │
│ - tunnel UI │
└──────────────┬──────────┘
│
▼
Docker Engine / sockets
│
┌──────────────┼───────────────┐
▼ ▼ ▼
app container devcontainer another app
myapp:3000 api:8000 frontend:5173
The project runs as one container that contains:
- Nginx
- a Go controller process
- both the generated config and the route discovery logic
- the optional tunnel management UI
devcontainer-nginx/
├── cmd/
│ ├── main.go # startup, reconciliation, homepage server
│ ├── homepage.go # diagnostics and HTML rendering
│ ├── tunnel_ui.go # tunnel UI HTML and JS
│ ├── tunnels.go # tunnel request lifecycle and route generation
│ └── ...
├── internal/
│ ├── discovery/
│ │ ├── docker.go # Docker API client
│ │ ├── identifier.go # devcontainer detection heuristics
│ │ └── resolver.go # network/port resolution
│ └── nginx/
│ └── config.go # NGINX config generation and validation
├── entrypoint.sh # certificate bootstrapping and process startup
├── docker-compose.yml # local dev setup
└── README.md
When the proxy starts, it:
- reads the Docker socket
- lists currently running containers
- inspects each container's metadata
- classifies likely dev targets
- resolves a route target (shared network, published port, or forwarded port)
- generates a route entry and writes NGINX config
- validates and reloads NGINX
The implementation uses conservative heuristics rather than requiring app changes:
- explicit Dev Container metadata when present
- Docker Compose metadata when present
- ordinary container naming patterns
- system container exclusions
The discovery layer keeps the workflow permissive while avoiding obvious non-app containers.
The resolver prefers the best available route target in this order:
- explicit
devcontainer.application.port - forwarded ports from devcontainer metadata
- other published or exposed ports
- common web ports as a fallback
- skip containers that are still ambiguous
Generated hostnames are based on:
devcontainer.hostnameif set- Compose project naming when available
- a sanitized container name otherwise
The final route is built as:
<hostname>.<base-domain>
The config generator produces:
- default HTTP and HTTPS listeners
- one upstream per route
- proxy blocks for each hostname
- websocket-friendly proxy headers
- TLS certificate settings for the external listener
- optional upstream certificate verification settings
The generator deduplicates by hostname and replaces stale entries with newer route data when duplicate names appear.
For tunnel routes that use HTTPS upstreams, the config generator emits:
proxy_ssl_server_name on;proxy_ssl_name <backend-hostname>;proxy_ssl_verify on|off;proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;when verification is enabled
This keeps certificate checks secure by default while allowing explicit opt-out with the tunnel form checkbox.
The app watches Docker events and re-runs reconciliation after a debounce period to avoid churn from rapid startup and shutdown events.
- Docker socket is mounted read-only
- the proxy only reads metadata and does not mutate containers
- it avoids logging sensitive values like certificate contents or app secrets
- generated config is validated before reload
When config changes, the process:
- builds a fresh route set
- writes the config atomically
- runs
nginx -t - reloads nginx only if validation passes
- keeps the latest valid config in place if validation fails
Tunnels are represented as TunnelRequest / TunnelEntry objects and converted into nginx routes via TunnelManager.NginxRoutes(). The route includes:
- hostname
- local port
- upstream protocol
VerifySSLVerifyName(the ALB or upstream host to validate against)
This is how the UI checkbox and generated config remain aligned with the actual HTTPS certificate verification behavior.