Container linking (legacy) vs networks
Why --link gave way to user-defined networks.
If you learned Docker from an older tutorial, you have almost certainly typed --link. It was the original way to let one container reach another. docker run --link db:database told the web container where the database lived and let it connect using the alias database. The flag still parses on Docker 27, so it looks alive. It is not. --link has been deprecated for years, and the reasons it lost are the same reasons user-defined networks became the default answer. That makes it worth understanding even though you should never build anything new on it. Every way --link failed maps onto something the modern network model was designed to fix.
A link is like taping a sticky note to the web container that reads: the database is at 172.17.0.2, and here is a photocopy of everything in its wallet while I am at it. Two things happen when you link a pair of containers. Docker writes a line into the client's /etc/hosts file (the small text file a machine reads before it bothers asking a name server) so the alias resolves to the target's current IP address (IP is short for Internet Protocol, the numbering scheme machines use to find each other). Then it copies the target container's environment variables into the client as brand-new variables, each one prefixed with the alias. That second half sounds convenient. It is where nearly all of the trouble starts, and you can watch both effects land the moment the link is made.
$ docker run -d --name db -e POSTGRES_PASSWORD=secret postgres:169f2a1c3e5b7d0a...$ docker run -d --name web --link db:database myapp:1.07c4b8e1f6a2d3c...$ docker exec web env | grep -i databaseDATABASE_NAME=/web/databaseDATABASE_PORT=tcp://172.17.0.2:5432DATABASE_PORT_5432_TCP_ADDR=172.17.0.2DATABASE_PORT_5432_TCP_PORT=5432DATABASE_ENV_POSTGRES_PASSWORD=secret # the target's secret, now in web's env$ docker exec web getent hosts database172.17.0.2 database 9f2a1c3e5b7d db
Four reasons links fell over
Four problems, and any one of them on its own is enough to sink the design. First, the hosts entry is written once, at start time. Recreate the database container, watch it come back on a different address, and the client's /etc/hosts still points at the dead one. The connection fails. Second, the environment copy hands the target's configuration to the client, secrets included. Look at that DATABASE_ENV_POSTGRES_PASSWORD line again. That is a database password sitting in a container that has no business holding it. Third, links only work on the default bridge network, which happens to be the one network with no built-in name resolution, so you are pinned to the weakest option Docker offers. Fourth, a link runs one way between exactly one pair of containers. You wire every pair by hand and end up with a brittle web of individual containers instead of a service that anything on the network can reach.
The modern way: your own network
A user-defined network is closer to moving both containers into the same office building, one with a receptionist who always knows which desk each person is sitting at, even after somebody swaps rooms. That receptionist is Docker's embedded DNS server (DNS stands for Domain Name System, the machinery that turns a name like a website address into a number a machine can dial). Create your own network, attach containers to it, and Docker runs a small DNS server at 127.0.0.11 inside each one. Ask for db and it hands back the database's current address, looked up fresh on every single request. No hosts file frozen at boot. No configuration copied out of one container into another.
There is nothing to link. You create a network, put both containers on it, and let them find each other by name. That is the whole migration. Two extras come along that --link never really offered. Isolation: containers on appnet can talk to each other, while containers sitting on a different user-defined network cannot see them at all, so you can separate a frontend from a backend without writing a single firewall rule. And network aliases: extra names that resolve to the same container, which is the thing the old single-alias link was clumsily imitating.
$ docker network create appnetb1d9f4a2c8e7...$ docker run -d --name db -e POSTGRES_PASSWORD=secret --network appnet postgres:16$ docker run -d --name web --network appnet myapp:1.0$ docker exec web getent hosts db172.19.0.2 db$ docker exec web env | grep -i postgres$ # nothing leaked: the db's env stays with the db
Prove it with a real query
Name resolution is only half the claim. The question that decides it is whether an application can open a connection over that name and get real work done. So run a throwaway Postgres client on the same network, point it at the database by name (-h db), and ask for something the server has to answer properly. If a row comes back, the plumbing is genuine and you are not reading a lucky ping.
$ docker run --rm --network appnet -e PGPASSWORD=secret postgres:16 \psql -h db -U postgres -c 'SELECT version();'version-----------------------------------------------------------------------PostgreSQL 16.3 (Debian 16.3-1.pgdg120+1) on x86_64-pc-linux-gnu, ...(1 row)
Now for the part --link could never manage. Destroy the database and bring it straight back. It will almost certainly land on a different address, because Docker hands out addresses as containers come and go. Under --link the client would still be aiming at an address that no longer exists. On a user-defined network the name resolves to the new address on the very next lookup, so the same query keeps working and nothing about the client changes.
$ docker inspect -f '{{.NetworkSettings.Networks.appnet.IPAddress}}' db172.19.0.2$ docker rm -f db && docker run -d --name db -e POSTGRES_PASSWORD=secret --network appnet postgres:16$ docker inspect -f '{{.NetworkSettings.Networks.appnet.IPAddress}}' db172.19.0.4 # new IP after recreate$ docker run --rm --network appnet -e PGPASSWORD=secret postgres:16 \psql -h db -U postgres -c 'SELECT 1;'?column?----------1(1 row)
You will still run into --link
Old Stack Overflow answers, old blog posts, and old docker-compose.yml files still show --link and the Compose links: key. Read them the way you would read a museum plaque. When you see --link db:database, translate it in your head to: put both containers on a network and call one of them db. In Compose you do not even do that much by hand. Every stack gets its own user-defined network automatically and services resolve each other by service name, which is why a link almost never turns up in a current Compose file.
DATABASE_ENV_POSTGRES_PASSWORD=secret line is not an artifact of the demo. For every environment variable set on the linked container, Docker injects a matching <ALIAS>_ENV_<VAR> into the client. The database password ends up in the web container's environment, where any process running there can read it, and so can anyone who types docker inspect web. A user-defined network injects nothing at all. If you inherit a stack that still uses --link, move it onto a network and file it as a live secret-exposure bug. This is a security finding, not a stylistic preference somebody never got around to updating.Why links lost, in review terms
--link wrote /etc/hosts entries and dumped a copy of the target's environment into the source container. It coupled containers tightly, leaked secrets sideways between siblings, and fell apart whenever containers were recreated in a different order. User-defined networks plus DNS give you stable names without copying anything between containers. Old Compose files and old tutorials still mention --link, and the right way to read them is as historical records.
Migration is not hard. Create a network, attach both services, connect by name, and give each service only the environment it actually needs, through Compose secrets or an explicit environment block. Weigh "it still works on Docker 27" against "it has been deprecated for years" and the answer is obvious. Do not build new systems on --link. If you look after a legacy stack that still uses it, put removal on the schedule before a future engine drops the flag for you and picks the timing itself.
In a security review, flag any link that forwards a database password into a web container through the environment. Networks do not magically clear up secret sprawl on their own. What they do is stop the automatic environment clone that a link performs behind your back, and in an old stack that clone is usually the single largest source of the sprawl.
When you cut a live stack over from links to a network, keep a short paper trail. Record the environment the client container was carrying before the change, because that list is your evidence of which secrets were leaking and for how long. Record the host you ran the commands on, and the exact run commands you replaced, since putting those --link flags back is your rollback. Note which containers you recreated too. A link migration means recreating both sides, and a service you forgot to restart is still holding the old copied environment even after the new network exists. A teammate should be able to replay the switch from the ticket alone, down to which name lookups they should expect to succeed afterwards.
Try this
Run this on a lab engine (Docker 24 or newer is fine). Read the sample output before you type anything, so you know what a working name lookup looks like ahead of the day you depend on one.
$ docker network create modern$ docker run -d --name db --network modern -e POSTGRES_PASSWORD=secret postgres:16$ docker run --rm --network modern alpine:3.20 ping -c 1 dbPING db (172.18.0.2): 56 data bytes1 packets transmitted, 1 packets received# STATUS: SUCCESS without --link — DNS on user-defined network
Takeaway
Go and search your own Compose files for links: and your own run scripts for --link. Every hit is two problems at once: containers welded to each other, and a password sitting in the environment of a container that never needed it. Replace each one with a shared network and a service name, then confirm the fix by checking that the client container's environment no longer mentions the database at all.
--link and onto a user-defined bridge network. Mid-deploy, the database container is destroyed and recreated, and it comes back on a different IP address. What happens to the web container's connection?--link is the thing you removed, and its frozen hosts entry is exactly what breaks on a recreate./etc/hosts entry, what does --link db:database do that a user-defined network does not, and that this lesson treats as a genuine security problem?web was started with --link db:database, and db's environment includes POSTGRES_PASSWORD=secret. A teammate with no access to db at all runs docker inspect web. What can they walk away with, and why?