Give the network of the stack IPv6, so that Metabase sees the address of each client
Context and Problem Statement
ADR-0019 left IPv6 off ("IPv4 first"). compose.yaml did not set enable_ipv6, so Docker was expected to hand the connection of an IPv6 client to the proxy from an address of its own, and Caddy and Metabase would see one address for every IPv6 client: the limit of Metabase on failed sign-ins from one address would count all of them together, and the logs would not show who connected. Until a check on a real server settled it, the documentation gave the domain an A record only, and warned against an AAAA record.
The check ran on 11 October 2026 on a Hetzner Cloud server (a CAX21 with arm64, Docker Engine 29.9.0 and Docker Compose 5.6.0), with a certificate of Let's Encrypt. With the networks as they were, a sign-in to Metabase over IPv6 worked, and Metabase recorded the gateway of the network of the proxy (172.19.0.1) as the address of the client: the same address for every IPv6 client.
Then the network default, which the proxy and Metabase share, got enable_ipv6: true and no subnet. Docker gave it a subnet of Unique Local Addresses of its own (fd58:f1d7:4ea9::/64), as its documentation says it does when default-address-pools has no IPv6 pool and no subnet is set (IPv6 with Docker, as of October 2026). Metabase then recorded the real address of an IPv6 client and the real address of an IPv4 client, the 32 tests of tests/stack/test_server.py passed on the server, and the site answered over IPv6 from outside.
In which modes does the network of the stack get IPv6, and how?
Decision Drivers
- Metabase sees the real address of each client, IPv4 or IPv6, because its limit on failed sign-ins is per address and its logs name the address. Only server mode has such clients: in local mode Metabase listens on
127.0.0.1only. - A domain may carry an AAAA record, such as one that a registrar adds by default, and a server of a provider such as Hetzner Cloud has an IPv6 address: the site should answer over both.
- Docker Engine before 27.0.1 cannot create an IPv6 network without a subnet in the Compose file, and local mode accepts Docker Engine 25.0 (ADR-0014); server mode already needs 28.0.0 (ADR-0019).
- The stack starts with one command, and the person changes nothing on the host.
- Local mode, server mode and CI keep working.
Considered Options
- Keep IPv4 only, and keep the warning against an AAAA record
- IPv6 on the network
defaultin every mode - IPv6 on the network
defaultin server mode only, by a setting that./agoranomos init --serverwrites - A pool of IPv6 addresses in
default-address-poolsin/etc/docker/daemon.json, with IPv6 on the network
Decision Outcome
Chosen option: "IPv6 on the network default in server mode only, by a setting that ./agoranomos init --server writes", because it gives Metabase the address of every client where there are clients, it is one setting in a file that the stack already ships, it changes nothing on the host, and local mode keeps its minimum of Docker Engine.
In practice this means:
- The setting.
compose.yamlsetsenable_ipv6: ${AGORANOMOS_IPV6:-false}on the networkdefault, which Airflow, Metabase and the proxy use, and sets no subnet. Compose reads the string as a boolean (checked on Compose 5.5.1; the interpolation of that field casts it in the source of compose-go since v1.20.2, and Compose 2.24.0 uses compose-go 2.0.0-beta.3)../agoranomos init --serverwritesAGORANOMOS_IPV6=truewhen the setting is not set, as it writesAGORANOMOS_SITE_URL, and a plaininit, whichupruns, does the same for a.envthat lists theserverprofile and lacks the setting, such as that of a server set up before this change, and says so.compose()gives Compose the value of.envfor it, as for theCOMPOSE_*settings, so that a value exported in the shell cannot differ from what the checks compare. It is not in.env.example, so local mode keepsfalse. The networkdata, which is internal and holds the databases and the broker, never has IPv6: it has no client to tell apart. - Docker. Server mode needs Docker Engine 28.0.0, above 27.0.1, the first release that uses a Unique Local Address prefix for an IPv6 network when no subnet is set; before it, an IPv6 network needed a subnet from the option
--subnetor from an IPv6 range indefault-address-pools(the release notes of 27.0.1, 24 June 2024)../agoranomos preflightstops on a value of the setting that is neithertruenorfalse, and ontruewith a Docker Engine older than 27.0.1, which a person can set in local mode by hand. - The guard. Compose recreates a network when anything in its definition changes (it keeps a hash of the definition in a label of the network), and it then reconnects the containers that it keeps without the aliases of their services (
execConnectNetworkinpkg/compose/executor_ops.go, the same in Compose 5.5.1 and 5.6.0, as of October 2026).metabase-init, which is on the networkdefaultonly, then does not find Metabase,upfails after 600 seconds, and it fails again when it runs again. This was seen with Compose 5.5.1 of Docker Desktop (Docker Engine 29.8.1), and it is a candidate for a report to the Compose project. Declaring the networkdefaultchanges its definition for every existing stack, also with the setting off, since the network was implicit before: with the declaration of this record and the setting unset, Compose removed and made the network again on a stack that an earlier release had started (hash and network ID changed). So the network carries the labeldev.agoranomos.ipv6with the value of the setting, and./agoranomos upandrestorelook at<project>_defaultafterpreflight, and when it exists and lacks the label, has another value in it, or has the otherEnableIPv6, they rundocker compose downfirst, never with the volumes, and say so.restoredoes it after it has removed what a mode left behind and before it stops the services, so the up at its end makes the network and the containers together. The data and the certificates stay in their volumes; the stack is down from that stop untilupends. Every existing stack stops once at its firstupafter this change, local mode too, and once more at each change of the setting. A later change to the definition of the network needs a label of its own and a check of it in the script. - The probe and the images. With
AGORANOMOS_IPV6=true, the guard first makes a network with--ipv6under a name of its own and removes it. If Docker refuses,upandrestoreend with Docker's first line of error, say that the stack is not stopped, and nameAGORANOMOS_IPV6=falsein.envas one way out, without saying that IPv6 is the cause when the error says something else. Then it builds the images and pulls the missing ones, so that a failure leaves the stack running, as it did before the guard;compose upfinds them in the cache.metadata difflooks at the network read-only and says to runupfirst, since Compose cannot run it on the network that a release changes. The message ofupwhen the stack does not start names the same way out when the setting istrue. - The documentation. The guide Deploy on a server gives the domain an AAAA record as well, with the IPv6 address of the server, tells how the update goes, and has a troubleshooting bullet for the stop of the guard and one for a host that cannot make the network. The messages of
init --serverand of the check of the certificate name the AAAA record next to the A record. - Tests.
tests/stack/test_variants.pychecks the rendered Compose files of every variant with the settingtrue,falseand empty: IPv6 and the label on the networkdefaultonly withtrue, no subnet, none ondata.tests/stack/test_compose.pychecks the network that Docker made against.env, andtests/stack/test_server.pythat server mode has IPv6. The unit tests check whatinitwrites, the checks ofpreflight, the guard ofupandrestore(a label missing, another label, the otherEnableIPv6, an equal network, no network, its place afterpreflight, and that it never uses the volumes), and the probe.
The other options were not chosen for these reasons:
- IPv4 only keeps the one address for every IPv6 client, and with it the shared limit and the logs without the client, and an AAAA record, which a registrar may add on its own, would reach the site through the gateway of the network.
- IPv6 in every mode needs Docker Engine 27.0.1 in local mode too, to give Metabase the address of clients that local mode does not have, since Metabase listens on
127.0.0.1there. - A pool in
daemon.jsonchanges the host, outside the stack, for something that Docker does by itself since 27.0.1: it adds a Unique Local Address base prefix todefault-address-poolswhen none has an IPv6 prefix.
Consequences
- Good, because an AAAA record works in server mode, and Metabase sees the real address of an IPv6 client, so IPv6 clients no longer share one limit on failed sign-ins. A client usually holds a whole /64 of addresses, so a limit per address is weaker for IPv6 than for IPv4.
- Good, because local mode and its minimum of Docker Engine 25.0 do not change, and the person changes nothing on the host.
- Bad, because every existing stack, local mode included, is stopped once at the first
./agoranomos upafter this change, and a server is down for those minutes; so is a stack at each later change of the setting. - Bad, because a host on which Docker cannot make an IPv6 network, such as one whose
ip6tablesdoes not work (the release notes of 27.0.1: the daemon starts, and it is not possible to create an IPv6-enabled network) or one with IPv6 disabled in the kernel, for exampleipv6.disable=1(Docker Engine 28.0.1 lets the daemon start there; whether a network can be made is for the probe to tell), cannot run server mode with the default setting. The probe stopsupandrestorebefore the stack is stopped, and the person setsAGORANOMOS_IPV6=falseand loses the AAAA record. - Bad, because the guard is a look-up of the network by the name of the project and a stop that the person did not ask for, and a Compose that does not lose the aliases would not need it.
- Neutral, because the containers on the network
defaultget IPv6 addresses too. Withip6tableson, which is the default since 27.0.1, only published ports are reachable from outside, and outgoing connections are masqueraded as the host (the same release notes), as for IPv4.
More Information
- This record replaces the bullet "IPv4 first" of ADR-0019, and its consequence that the site is reached over IPv4 only, and nothing else of that record; ADR-0019 stays accepted.
- Implementation status, as of October 2026: in place in the pull request that proposes this record:
AGORANOMOS_IPV6and its label incompose.yaml, written byinit, the checks ofpreflight, the guard and the probe inupandrestore, the tests above, and the guide. Checked on a Hetzner CAX21 on 11 October 2026 (above, with the option set in the file by hand), and on Docker Desktop for macOS (Docker Engine 29.8.1, Compose 5.5.1): a stack started at the commit before this change (local mode, the setting unset) was moved to this change with./agoranomos up, which stopped it once, made the network again with the label, and came up healthy withmetabase-initdone and the data kept, and a secondupstopped nothing; withAGORANOMOS_IPV6=true,upprobed, stopped the stack once and made the network with IPv6 and a Unique Local Address subnet, and the same back withfalse. A plaindocker compose createwith the declaration of the first version of this change and the setting unset removed and made the network again too, with its hash changed. - Docker: IPv6 with Docker, and the release notes of Docker Engine 27.