An app connects to Redis at localhost:6379 in every environment, but Redis never actually lives at the same place twice: a local container in dev, a managed instance in staging, a different managed instance in prod. Something has to resolve that difference, and the easiest place to put it is scattered if ENV == "prod" branches in the app’s own config. The ambassador pattern puts it in one disposable container instead.
The setup
An ambassador is a proxy sidecar that shares the app’s network namespace and forwards a fixed local address to wherever the real dependency actually is:
services:
app:
build: .
network_mode: "service:db-ambassador"
depends_on:
- db-ambassador
environment:
- REDIS_HOST=localhost
- REDIS_PORT=6379
db-ambassador:
image: haproxy:3.0
configs:
- source: ambassador_cfg
target: /usr/local/etc/haproxy/haproxy.cfg
configs:
ambassador_cfg:
content: |
defaults
mode tcp
timeout connect 5s
timeout client 1m
timeout server 1m
frontend redis_in
bind *:6379
default_backend redis_out
backend redis_out
server redis1 ${REDIS_REMOTE_HOST:?set me}:${REDIS_REMOTE_PORT:-6379}
network_mode: "service:db-ambassador" puts app in db-ambassador’s network namespace, so app sees localhost:6379 as if Redis were running right next to it. REDIS_REMOTE_HOST/REDIS_REMOTE_PORT come from a .env file or the shell, Compose’s own variable substitution, not the container’s environment: block, and it applies just as well inside an inline configs: block’s content:, so switching targets is a value in .env, not a line of app code:
# .env.staging
REDIS_REMOTE_HOST=redis.staging.internal
REDIS_REMOTE_PORT=6379
Why it’s not just another sidecar
Tip #47 covered the sidecar pattern broadly: any helper container handling a concern the main service shouldn’t carry itself (log shipping, a local cache, a metrics exporter). An ambassador is the specific flavor that only ever does one thing, stand in for a remote dependency on the local network, so the app never has to know, or care, where that dependency physically lives. The app’s code stays identical across dev, staging, and prod; only the ambassador’s target changes.
Why it matters
Environment-specific connection logic (retries, TLS, service discovery, failover) lives in one small, replaceable container instead of being re-implemented or re-configured inside every service that needs the same dependency. HAProxy’s own config already covers TLS termination and backend health checks, so adding either is a change to haproxy.cfg, not new code in every dependent service.
Pro tip: one ambassador per namespace
network_mode: "service:x" shares the entire network namespace, not just one forwarded port. A container can only join one other container’s namespace at a time, so if app needs ambassadors for both Redis and Postgres, that’s two separate proxies, each needing its own sidecar relationship, not one ambassador juggling both. Plan for one dependency per ambassador, and expose only the ports that dependency actually needs.