The first time I built a multi-container app in Docker, I reached straight for --link. It seemed to work — my app container could reach my database container by name, environment variables appeared magically inside the container, and everything felt connected. It took me a while (and one confusing production migration) to learn that --link is a legacy feature Docker has explicitly deprecated in favor of user-defined networks, and that leaning on it in new projects is a mistake.
That said, --link still shows up constantly — in old tutorials, in legacy Compose files, in codebases that haven’t been touched in years. So in this guide, I want to cover both sides honestly: how container linking actually works, why it existed, why you shouldn’t use it going forward, and exactly what to replace it with.
What Container Linking Was Designed to Solve
Before Docker had embedded DNS-based service discovery on user-defined networks (introduced around Docker 1.9), containers on the default bridge network had no way to resolve each other by name. If you wanted app to talk to db, you had to either hardcode db‘s IP address (which changes every time the container restarts) or use --link to get Docker to inject connection info automatically.
--link solved three problems at once:
- Name resolution — added an entry to
/etc/hostsinside the container - Environment variables — injected variables describing the linked container’s exposed ports and IP
- Implicit ordering — the linked container had to already exist and be running
How --link Works: A Practical Example
Let’s set up a classic two-container example: a Redis container and an application container linked to it.
docker run -d --name redis_server redis:7
Now link an application container to it:
docker run -it --name my_app --link redis_server:redis busybox sh
The syntax is --link <container_name>:<alias>. Inside my_app, check /etc/hosts:
cat /etc/hosts
Expected output (IP will vary):
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
172.17.0.2 redis
172.17.0.2 redis_server 172.17.0.2 redis_server
Docker added redis as a resolvable hostname pointing to the Redis container’s IP. Now, from inside my_app:
ping -c 2 redis
Expected output:
PING redis (172.17.0.2): 56 data bytes
64 bytes from 172.17.0.2: seq=0 ttl=64 time=0.077 ms
Environment Variables Injected by --link
Check what environment variables were injected:
env | grep REDIS
Expected output (depends on the image’s exposed ports):
REDIS_NAME=/my_app/redis
REDIS_PORT=tcp://172.17.0.2:6379
REDIS_PORT_6379_TCP=tcp://172.17.0.2:6379
REDIS_PORT_6379_TCP_ADDR=172.17.0.2
REDIS_PORT_6379_TCP_PORT=6379
REDIS_PORT_6379_TCP_PROTO=tcp
This is exactly the mechanism a lot of older application frameworks were built around — reading REDIS_PORT_6379_TCP_ADDR from the environment at startup to configure a connection.
Linking Multiple Containers
You can link a container to several others at once:
docker run -d --name mysql_db -e MYSQL_ROOT_PASSWORD=secret mysql:8
docker run -d --name redis_cache redis:7
docker run -it --name webapp \
--link mysql_db:mysql \
--link redis_cache:redis \
busybox sh
Both mysql and redis become resolvable hostnames inside webapp.
Linking in Legacy docker-compose.yml (Version 1/2 Syntax)
Old Compose files used a top-level links key:
version: "2"
services:
web:
image: myapp:latest
links:
- db
- redis
db:
image: postgres:16
redis:
image: redis:7
If you inherit a project with this pattern, know that it’s functionally equivalent to using --link on docker run, with the same limitations described below.
Why --link Is Deprecated
Docker’s own documentation flags --link as a legacy feature that may eventually be removed, and I stopped using it in any new project years ago, for concrete reasons:
1. Static, one-directional, and fragile. The /etc/hosts entries and environment variables are set once, at container creation. If the linked container is removed and recreated (getting a new IP), the linking container’s /etc/hosts entry does not update automatically — you have to restart the linking container too. In a world of rolling deploys and container restarts, this is a serious liability.
2. No support for container-to-container communication in both directions by default in the same intuitive way user-defined networks provide — --link is one-way (the linked container doesn’t automatically know about the linking container).
3. Doesn’t work across hosts. --link is purely local, single-host functionality; it has no answer for Swarm or multi-host deployments the way overlay networks do.
4. Superseded entirely by embedded DNS. Since Docker 1.9, any container on a user-defined network automatically gets DNS-based name resolution to every other container on that same network — dynamically, and correctly updated on restarts. There’s no reason to reach for the older, more fragile mechanism.
The Modern Replacement: User-Defined Networks
Here’s the direct migration from the linking example above:
docker network create app_net
docker run -d --name redis_server --network app_net redis:7
docker run -it --name my_app --network app_net busybox sh
Inside my_app:
ping -c 2 redis_server
Expected output:
PING redis_server (172.20.0.2): 56 data bytes
64 bytes from 172.20.0.2: seq=0 ttl=64 time=0.065 ms
Notice I didn’t need --link at all — any container on app_net can resolve any other container on app_net by its container name automatically, and this resolution stays correct even if redis_server is restarted and gets a new IP, because it’s DNS-based, not a static /etc/hosts entry.
Modern Compose Equivalent
version: "3.9"
services:
web:
image: myapp:latest
networks:
- app_net
db:
image: postgres:16
networks:
- app_net
redis:
image: redis:7
networks:
- app_net
networks:
app_net:
driver: bridge
With modern Compose (v2 syntax, no version: key needed with current Docker Compose versions, though it’s still widely supported for compatibility), services on the same Compose file are automatically placed on a shared default network with DNS resolution — you don’t even need to declare app_net explicitly unless you want multiple isolated networks:
services:
web:
image: myapp:latest
depends_on:
- db
- redis
db:
image: postgres:16
redis:
image: redis:7
Here, web can reach db and redis by service name automatically — Compose creates a default project network behind the scenes.
Bring it up and confirm:
docker compose up -d
docker compose exec web ping -c 2 db
Replacing Environment-Variable-Based Config
If you’re migrating a legacy app that reads connection info from --link-injected environment variables (REDIS_PORT_6379_TCP_ADDR, etc.), the cleanest modern approach is to pass explicit environment variables yourself, pointing at the DNS name:
services:
web:
image: myapp:latest
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
depends_on:
- redis
redis:
image: redis:7
This is more explicit, more portable across orchestration systems, and doesn’t depend on Docker-specific runtime injection at all — which matters a lot once you move toward Kubernetes, where this injection mechanism doesn’t exist in the same form.
Kubernetes Equivalent: Service Discovery
If you’re migrating a linked multi-container Docker setup toward Kubernetes, the conceptual equivalent of “linking” is Kubernetes’ built-in Service DNS:
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7
ports:
- containerPort: 6379
Any Pod in the same namespace can now resolve redis (or redis.<namespace>.svc.cluster.local for cross-namespace resolution) via Kubernetes’ internal DNS (CoreDNS) — the direct spiritual successor of what Docker’s user-defined network DNS does at the single-host level.
Security Considerations
One thing worth knowing: --link also had a side effect of implicitly allowing traffic between linked containers even when inter-container communication (icc) was disabled on the bridge (--icc=false in the daemon config). This was actually one of --link‘s few remaining legitimate uses — selectively allowing communication in an otherwise locked-down bridge network.
The modern equivalent is user-defined networks with network segmentation — put only the containers that need to talk to each other on the same network, and use multiple networks to isolate groups:
docker network create frontend_net
docker network create backend_net
docker run -d --name web --network frontend_net myapp:latest
docker network connect backend_net web # web can reach backend services
docker run -d --name db --network backend_net postgres:16
docker run -d --name cache --network frontend_net redis:7
Here, db is isolated on backend_net and only reachable by containers explicitly connected to that network (like web, which I attached to both), while cache sits on frontend_net and never sees backend_net traffic at all.
Troubleshooting Legacy Linked Containers
Linked container’s hostname stops resolving after a restart: This is the classic --link staleness problem. Restart the linking container too:
docker restart my_app
Or, better, migrate off --link entirely to a user-defined network.
“Cannot link to a non running container” error: --link requires the target container to be running at the time the linking container is created:
docker start redis_server
docker run --link redis_server:redis ...
Environment variables missing inside the container: Confirm the linked image actually declares EXPOSE for the ports you expect — --link‘s environment variable injection is derived from the target image’s EXPOSE directives, not from what’s actually listening.
docker inspect redis_server --format '{{.Config.ExposedPorts}}'
Best Practices
- Don’t use
--linkin any new project. It’s retained purely for backward compatibility. - Migrate legacy Compose files using
links:tonetworks:as soon as it’s practical — the change is usually low-risk since DNS resolution behavior is a superset of what linking offered. - Use multiple user-defined networks for segmentation instead of relying on
--icc=falseplus selective--linkexceptions. - Pass explicit environment variables for service connection details rather than depending on Docker’s auto-injected
--linkvariables, for portability toward Kubernetes or other orchestrators. - If you inherit a codebase using
--link, treat it as technical debt worth scheduling time to remove, not a pattern to extend.
Summary
Container linking (--link) was Docker’s original answer to service discovery — injecting /etc/hosts entries and environment variables so containers could find each other by name. It’s been functionally superseded by user-defined networks with embedded DNS, which are more robust (correctly updating on container restarts), more flexible (support many-to-many, cross-network communication via docker network connect), and conceptually closer to what you’ll use later in orchestrators like Docker Swarm or Kubernetes. If you’re starting something new, skip --link entirely and reach for docker network create and DNS-based service names from day one.
References
- Docker Documentation: Legacy container links
- Docker Documentation: Networking overview
- Docker Documentation: Docker Compose networking
- Kubernetes Documentation: DNS for Services and Pods
- CNCF: CoreDNS Project
