CoursesDocker in depthContainer linking (legacy) vs networks

Container linking (legacy) vs networks

Why --link gave way to user-defined networks.

Intermediate10 min · lesson 14 of 30

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.

terminal
$ docker run -d --name db -e POSTGRES_PASSWORD=secret postgres:16
9f2a1c3e5b7d0a...
$ docker run -d --name web --link db:database myapp:1.0
7c4b8e1f6a2d3c...
$ docker exec web env | grep -i database
DATABASE_NAME=/web/database
DATABASE_PORT=tcp://172.17.0.2:5432
DATABASE_PORT_5432_TCP_ADDR=172.17.0.2
DATABASE_PORT_5432_TCP_PORT=5432
DATABASE_ENV_POSTGRES_PASSWORD=secret # the target's secret, now in web's env
$ docker exec web getent hosts database
172.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.

Two ways to connect two containers
web needs to reach db
one flag, or one shared network
legacy
--link db:database
deprecated · default bridge only · injects the target's env vars (leaks secrets) · hosts entry frozen at start, breaks on recreate · one pair at a time
modern
--network appnet
use this · embedded DNS resolves db by name · resolution follows a recreated container · nothing copied between containers · isolation + aliases for free
Anything --link did, a name on a shared user-defined network does better. Compose builds that network for you.

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.

terminal
$ docker network create appnet
b1d9f4a2c8e7...
$ 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 db
172.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.

terminal
$ 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.

terminal
$ docker inspect -f '{{.NetworkSettings.Networks.appnet.IPAddress}}' db
172.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}}' db
172.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.

A link hands the database password to the web container
That 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.

terminal
$ 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 db
PING db (172.18.0.2): 56 data bytes
1 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.

Quick check
01You have moved a two-container stack off --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?
Correct — Resolution on a user-defined network follows the container through a recreate, so the name always points at whatever address it holds right now.
Incorrect — No. That restart dance is what you need when an address has been hardcoded. DNS on a user-defined network gives you the live address every time you ask.
Incorrect — There is no lifetime cache like that. The embedded DNS server at 127.0.0.11 answers with the container's current address.
Incorrect — No. --link is the thing you removed, and its frozen hosts entry is exactly what breaks on a recreate.
02Apart from writing an /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?
Correct — You watched DATABASE_ENV_POSTGRES_PASSWORD=secret appear in web's environment, and anyone who can read that environment now holds db's password.
Incorrect — No. --link is container-to-container wiring and publishes nothing on the host.
Incorrect — No. --link grants no privilege inside the other container. The exposure is the copied environment variables.
Incorrect — No. --link adds no encryption whatsoever. Its problems are leaked secrets and a hosts entry frozen at start time, not transport security.
03A container called 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?
Incorrect — No. --link copies the target's environment variables into the client, so the secret is sitting in web's environment waiting for inspect to print it.
Incorrect — No. The address is shared through /etc/hosts, but the environment variables are copied across as well, and that copy is the leak.
Correct — The alias-prefixed copy puts db's password into web's environment, readable by any process inside web and by anyone running docker inspect web.
Incorrect — No. A user-defined network copies nothing between containers. The leak comes entirely from --link's environment injection.

Related