docker-net-dhcp

A Docker network plugin that leases every container's address from the
DHCP server your LAN already runs, over bridge, macvlan or ipvlan,
for IPv4 and IPv6. One docker network create, then networks: [lan-dhcp]
in any Compose file. No static addresses, no sidecars, no plumbing per
container.
The problem it solves: with plain macvlan or static addressing, a
container can come back from an update or a restart with a different
address, or two containers can end up on one address. This plugin leases
each address from the LAN's DHCP server and, in bridge and macvlan
mode, keeps one identity per container across restarts and upgrades, so
the router's lease table and its reservations stay true.
Every release is run in a separate lab against real DHCP servers, Kea
(single, hot-standby pair and behind a relay), ISC dhcpd (single and
failover pair), dnsmasq, udhcpd, Pi-hole and OpenWrt, with Docker hosts
on Debian 13, Ubuntu 24.04 and Debian 11 with Docker 20.10; a MikroTik
RouterOS CHR cell is set up but has written no verdicts yet
(latest lab results,
how the lab works).
This is the successor of devplayer0/docker-net-dhcp, not a patched copy:
2.x has its own DHCP engine and its own lifecycle code.
sudo mkdir -p /var/lib/net-dhcp # once per host
docker plugin install ghcr.io/claymore666/docker-net-dhcp:v2.7.0 # -arm64 on arm64
docker network create -d ghcr.io/claymore666/docker-net-dhcp:v2.7.0 \
--ipam-driver null -o mode=macvlan -o parent=eth0 lan-dhcp
docker run --rm -ti --network lan-dhcp alpine ip address show
The parts and what passes between them are drawn in
docs/architecture.md.
It is a privileged plugin: it runs with host networking, the Docker socket
and CAP_NET_ADMIN. The full list is under Requirements,
and what each item is for is in SECURITY.md.
Feature list
- The address is the LAN's. The router's lease table, its MAC
reservations and, with
-o register_dns=true, its DNS see the container
as one more host.
- IPv6 in the same shape as IPv4.
-o ipv6_mode= picks DHCPv6, SLAAC,
or whatever the router advertisement says, per network, with the same
identity rules and the same counters. -o ipv6_pd=64 also asks for a
delegated prefix, for a container that routes one. A network created
with --ipv4=false (Docker 28 or later) runs no DHCPv4, and its
containers get IPv6 only (#1135).
- One identity per container, kept across restarts. In
bridge and
macvlan the plugin keeps the MAC, and with it the DHCP client id and
the DHCPv6 DUID, across docker restart, a daemon restart and a plugin
upgrade, and asks for the old address again. A MAC reservation on the
router keeps matching.
- ipvlan gets its own client id. ipvlan containers share the parent's
MAC, so each one gets its own DHCP client id (option 61) and its own
DUID (#895). That identity is new on every restart, so by default an
ipvlan container does not keep its address across
docker restart.
The client_id driver option gives a container its own client id that
keeps the address across a restart and a recreate (#219).
- VLANs, sub-modes, MTU.
-o vlan= puts a macvlan or ipvlan network
on a tagged VLAN off the parent (#902); -o macvlan_mode= and
-o ipvlan_mode= pick the kernel mode of each link (#905); -o mtu=
sets the link MTU (#1037).
- Bridge mode from a spare NIC. With
-o parent= the plugin makes the
bridge, enslaves the NIC and removes both with the network (#903);
-o force_create=true overrides the firewall check on that bridge.
- APIPA fallback while the DHCP server is down. With
-o link_local_fallback=true a container starts on a 169.254 address
(IPv4 only, bridge and macvlan), the plugin keeps asking and moves the
container to the lease in place when one arrives (v2.3.0, #904,
reference).
- Monitoring.
/Plugin.Health on
the plugin socket reports the lease state and the counters;
/metrics serves the same counters in
Prometheus text format, always on the socket and on a TCP listener when
METRICS_ADDR is set; -o audit_log=true appends every lease-lifecycle
event to a ledger under the state directory
(audit ledger).
- Leases follow Docker's lifecycle. Renewal, rebind, NAK and expiry run
in the plugin, one client per endpoint.
-o release_lease= hands the
address back at stop or at remove. Every Docker hook has a defined
outcome, including a container that leaves before its lease binds, a
network removed together with its bridge, and an endpoint Docker forgot.
- Measured on Docker Engine 20.10 through 29. The engine matrix builds
the plugin, creates networks in every documented shape, confirms a lease
in the DHCP server's own log and keeps an address across
docker restart, on every engine line, weekly and on every change to
the measurement. Below 20.10 the plugin refuses to start and names the
minimum.
Two shapes
| You want |
Use |
| The container takes its address from the LAN, Docker keeps no pool |
--ipam-driver null (the default shape, all three modes) |
Docker knows the address: docker run --ip, Compose ipv4_address, docker inspect |
the plugin as IPAM driver as well (--ipam-driver <plugin>), bridge and macvlan, IPv4 and IPv6; --ip6 and ipv6_address are served when an IPv6 --subnet is typed and ipv6_mode is dhcp (#1132) |
ipvlan |
--ipam-driver null |
|
IPv4 |
IPv6 |
as IPAM driver |
| bridge |
yes |
yes |
yes |
| macvlan |
yes |
yes |
yes |
| ipvlan |
yes |
yes |
no, needs a Docker change (#949) |
Unsupported
ipvlan with this plugin as IPAM driver. Docker assigns a MAC to
the ipvlan interface, and ipvlan interfaces cannot take one. Needs a
change in Docker, not planned (#949). ipvlan networks use
--ipam-driver null.
- Docker Engine 19.03. Unmeasured, not planned.
- IPv6-Only Preferred (DHCPv4 option 108, RFC 8925) is never requested. A
Docker endpoint on a network with an IPv4 pool must hold an IPv4
address, which makes it an IPv4-requiring host, and RFC 8925 section 3.2
forbids such a host from asking for the option. The DHCP library
implements the option for consumers that are not (#1027).
Where the plugin refuses, the message names the reason.
Planned
v2.7.0 adds a client_id driver option for one container (#219), an
EBUSY error that names the children the plugin holds on the parent
(#1372) and a SLAAC address that leaves no kernel prefix route behind
(#1412). v2.8.0 is planned: an IPAM-mode restart that keeps the
address on a DHCP server that files leases by MAC (#1416) and a fix
for renewal behind a DHCP relay (#1419)
(milestone).
The full list,
with what this project will not do, is on the roadmap.
How to check any of this
- The DHCP exchange runs inside the plugin on the project's own library,
dhcp-golib. No external
DHCP client, no client process per container.
- Tests assert on the wire and on the server: packet captures and the
DHCP server's lease log, not the plugin's own counters
(how this plugin is tested).
- The real-server lab installs each server the way its users install
it, on its own VMs, one isolated cell per server; every scenario is
judged against the server's own lease table, and the evidence of each
run is published (the lab,
docker-net-dhcp-lab).
- Every pull request carries a public review verdict, and every release
ships signatures, SLSA provenance and an SBOM
(verifying releases).
Requirements
- Docker Engine 20.10 or newer. 20.10 is the lowest version this
plugin is measured on. The engine matrix drives the whole baseline on
every engine line from 20.10 to the current release: the plugin
created from a local build and enabled,
docker network create in
bridge, macvlan and ipvlan mode, a lease confirmed in the DHCP
server's own log, and an endpoint that keeps its address across
docker restart. Pulling the published plugin from a registry is not
part of that measurement. It runs weekly and on every change to the
measurement. Below 20.10 the plugin refuses to start, and the refusal
names the minimum and the engine it saw; an engine older than 19.03
is named by the API version it reported. 19.03 is unsupported
because it is unmeasured: on a cgroup v2 host it cannot start a
container at all, so nothing there tests this plugin.
Every change is also tested against the engine the integration suite
runs on, 29.9.2 today, read from the engine drift check in that
run's Prepare the lane step.
- Plugin interface
docker.networkdriver/1.0, which is what the
plugin manifest declares. The plugin negotiates the Docker API version
with the daemon. It publishes both numbers on /Plugin.Health as
engine_version and api_version. The minimum above is a version of
the engine, not of the API. The one refusal on the API version is below
1.40, the oldest the plugin's Docker client speaks, which only engines
older than 19.03 report.
- One directory, created once per host, before
docker plugin install
(the line is in the quick start below). Docker will not create a missing
bind source, so without it the install fails at start-up and leaves the
plugin installed but disabled, after which the identical command
answers only plugin ... already exists and names nothing. Recovery:
the reference.
- Architecture.
linux/amd64 on the bare tag, linux/arm64 on the
-arm64 tag. A Docker plugin cannot be installed from a
multi-architecture manifest list, so the tag is how the architecture is
chosen, in every snippet that names the image and not only the
install line. Why, in full:
Install, upgrade, uninstall.
- Privileges. The manifest asks for
host networking, the host PID
namespace, the Docker socket, a bind mount of the state directory, a
read-only bind mount of /var/run/docker, and CAP_NET_ADMIN,
CAP_NET_RAW, CAP_SYS_ADMIN, CAP_SYS_PTRACE. docker plugin install prompts for the set; what each is for is in
SECURITY.md.
- Kernel link types. Bridge mode needs
veth and bridge; macvlan
and ipvlan each need the kernel module of the same name. A stock
distribution kernel loads one the first time that link type is asked
for, so there is normally nothing to do: measured on Linux 6.12,
ipvlan was absent from lsmod before the first
ip link add ... type ipvlan and present after, with no modprobe. A
kernel built without the type, or a host where module loading is
turned off, fails docker network create for that mode.
- Root on the host. The plugin runs as root and its socket lives
under
/run/docker/plugins, which only root can read. Reading
/Plugin.Health therefore needs
sudo. Without it curl -s prints nothing and exits 7, which is what
an absent socket also gives, so a permission problem looks like a
stopped plugin.
- Mode constraints.
bridge expects a host bridge you maintain,
or makes one from a spare NIC that stays up with no address (v2.3.0);
macvlan and ipvlan attach to a host NIC and change nothing on it,
at the cost of the kernel rule that a child cannot reach its own host's
address. With -o vlan=<id> (v2.3.0) they attach to a VLAN
sub-interface of that NIC, which the plugin creates when it is missing
and removes with the last network on it. Both in
macvlan / ipvlan modes.
Quick start
# Once per host, before the install. See Requirements above.
sudo mkdir -p /var/lib/net-dhcp
# amd64
docker plugin install ghcr.io/claymore666/docker-net-dhcp:v2.7.0
# arm64
docker plugin install ghcr.io/claymore666/docker-net-dhcp:v2.7.0-arm64
One network, created once. macvlan needs only a host NIC; bridge
wants a bridge you bring yourself (bridge mode):
docker network create -d ghcr.io/claymore666/docker-net-dhcp:v2.7.0 \
--ipam-driver null -o mode=macvlan -o parent=eth0 lan-dhcp
docker run --rm -ti --network lan-dhcp alpine ip address show
--ipam-driver null stops Docker handing out addresses that would
collide with the real LAN. From v2.1.0 there is a second supported
shape: name the plugin again in place of null and the leased address
goes into Docker's own address management, which makes --ip and
Compose's ipv4_address work.
docker network create -d ghcr.io/claymore666/docker-net-dhcp:v2.7.0 \
--ipam-driver ghcr.io/claymore666/docker-net-dhcp:v2.7.0 \
-o mode=macvlan -o parent=eth0 lan-dhcp
One of the two is required. On arm64 the -arm64 tag goes in these
lines too, because a network records the tagged reference as its driver.
Add -o ipv6_mode=dhcp for a DHCPv6 lease beside the v4 one, or
-o ipv6_mode=slaac to take the address from the router's
advertisement; auto reads the advertisement and does what it says,
DHCPv6 where it asks for DHCPv6 and the prefix where it does not. -o ipv6=true is the short spelling of dhcp. They work on both lines.
On the IPAM line --ipv6 needs a typed IPv6 --subnet <prefix> (a /126
or shorter) and -o ipv6_mode=dhcp: the plugin allocates no IPv6 pool of
its own, so the subnet is the pool Docker keeps its books in (#1132).
The DHCPv6 server picks each address, and --ip6 asks it for one; a
reply naming another address than --ip6, or one outside the subnet, is
refused and released, and the container does not start. Without a subnet
--ipv6 is refused
(#960), and -o ipv6=true or -o ipv6_mode=<mode> switches IPv6 on
instead. The modes are set out in
the driver reference,
and the two shapes in
the same page.
After that, plain Compose. No static addresses, no sidecar, nothing per
container:
services:
app:
image: nginx
networks: [lan-dhcp]
networks:
lan-dhcp:
external: true
Documentation
Published at https://claymore666.github.io/docker-net-dhcp/, one
version per release; the same pages live in docs/.
- Driver reference is the manual: every option,
setting and counter, install and upgrade, lease behaviour,
observability, Compose usage, troubleshooting.
- Bridge mode is the one-time host bridge setup.
- macvlan / ipvlan modes covers
choosing between them, and their constraints.
- Verifying releases covers signatures,
SLSA provenance, SBOMs, and rebuilding the binaries yourself.
- How it works is the mechanism, for contributors.
- How this plugin is tested is what is tested, where,
and what each result proves.
- Roadmap is where this is going, and what it will
not do.
- Contributing is what an acceptable pull
request looks like.
- Changelog · Release runbook
Images go to GHCR (ghcr.io/claymore666/docker-net-dhcp:vX.Y.Z, primary)
and are mirrored to Docker Hub under two names,
claymore666/net-dhcp:vX.Y.Z and
claymore666/docker-net-dhcp:vX.Y.Z. The two Hub names are the same
image at the same digest; install from either. What a major, minor or
patch release may change is in Versioning.
Origin and licence
This project is the successor of devplayer0/docker-net-dhcp
(quiet since 2021); since 2.0 it runs its own DHCP engine.
GPL-3.0. See LICENSE.md. The upstream project is GPL-3.0
and this derivative stays under the same licence.
Verifying releases
Every release from v1.1.0 is cosign-signed (keyless) on both registries
and ships an SBOM; SLSA build provenance is attested for the GHCR image
only, so verify provenance against the ghcr.io reference. Both
commands need cosign v3 or newer. v2 cannot read the bundle format
the release signs with, and it fails in a way that looks like a broken
signature. Replace VERSION:
cosign verify ghcr.io/claymore666/docker-net-dhcp:VERSION \
--certificate-identity-regexp '^https://github.com/claymore666/docker-net-dhcp/.github/workflows/release.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/claymore666/docker-net-dhcp:VERSION --repo claymore666/docker-net-dhcp
The whole procedure, including rebuilding the binaries yourself, is in
Verifying releases.
- Security policy / vulnerability reporting: SECURITY.md.
Do not open a public issue for a vulnerability.
- Bug reports & feature requests: the
issue forms.
Include the plugin version, your Docker version, the mode, and the
plugin log. Docker has no
plugin logs subcommand.
- Questions: ask in
Discussions;
each issue form stamps a type label, so a question has no honest home on
the tracker.
- Governance & code of conduct: GOVERNANCE.md,
CODE_OF_CONDUCT.md.
- Support: if the project saves you time, you can chip in via
PayPal.
Contributing
Contributions are welcome. Open a pull request against the dev branch;
Contributing is the whole of what is asked, and
make check runs the fast CI lane locally in about a minute. In short:
- Go code is
gofmt-formatted and passes go vet and
staticcheck; shell and workflow files pass
shellcheck and actionlint.
- New functionality is expected to ship with tests, and a per-package
coverage ratchet enforces that at release time.
- Commits and pull request descriptions carry no AI-assistant
attribution and the commit author is a person. The
attribution
check reads every commit and the description.
- Every required check must be green; branch protection holds the list.
- Looking for somewhere to start? There are no starter tasks open at the
moment. The ones that were seeded were closed as the work they described
landed, and this section says so instead of sending you to an empty list.
If you would like a first task,
ask in Discussions
and say what interests you: a subsystem, a bug you hit, a piece of the
documentation you found thin. One will be scoped against it. When
starter tasks exist again they carry the
good first issue label and this
section links to them.
This is solo-maintained, so please allow a few days for a response.