Skip to content

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.1 only.
  • 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 default in every mode
  • IPv6 on the network default in server mode only, by a setting that ./agoranomos init --server writes
  • A pool of IPv6 addresses in default-address-pools in /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.yaml sets enable_ipv6: ${AGORANOMOS_IPV6:-false} on the network default, 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 --server writes AGORANOMOS_IPV6=true when the setting is not set, as it writes AGORANOMOS_SITE_URL, and a plain init, which up runs, does the same for a .env that lists the server profile and lacks the setting, such as that of a server set up before this change, and says so. compose() gives Compose the value of .env for it, as for the COMPOSE_* 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 keeps false. The network data, 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 --subnet or from an IPv6 range in default-address-pools (the release notes of 27.0.1, 24 June 2024). ./agoranomos preflight stops on a value of the setting that is neither true nor false, and on true with 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 (execConnectNetwork in pkg/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 network default only, then does not find Metabase, up fails 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 network default changes 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 label dev.agoranomos.ipv6 with the value of the setting, and ./agoranomos up and restore look at <project>_default after preflight, and when it exists and lacks the label, has another value in it, or has the other EnableIPv6, they run docker compose down first, never with the volumes, and say so. restore does 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 until up ends. Every existing stack stops once at its first up after 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 --ipv6 under a name of its own and removes it. If Docker refuses, up and restore end with Docker's first line of error, say that the stack is not stopped, and name AGORANOMOS_IPV6=false in .env as 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 up finds them in the cache. metadata diff looks at the network read-only and says to run up first, since Compose cannot run it on the network that a release changes. The message of up when the stack does not start names the same way out when the setting is true.
  • 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 --server and of the check of the certificate name the AAAA record next to the A record.
  • Tests. tests/stack/test_variants.py checks the rendered Compose files of every variant with the setting true, false and empty: IPv6 and the label on the network default only with true, no subnet, none on data. tests/stack/test_compose.py checks the network that Docker made against .env, and tests/stack/test_server.py that server mode has IPv6. The unit tests check what init writes, the checks of preflight, the guard of up and restore (a label missing, another label, the other EnableIPv6, an equal network, no network, its place after preflight, 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.1 there.
  • A pool in daemon.json changes the host, outside the stack, for something that Docker does by itself since 27.0.1: it adds a Unique Local Address base prefix to default-address-pools when 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 up after 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 ip6tables does 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 example ipv6.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 stops up and restore before the stack is stopped, and the person sets AGORANOMOS_IPV6=false and 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 default get IPv6 addresses too. With ip6tables on, 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_IPV6 and its label in compose.yaml, written by init, the checks of preflight, the guard and the probe in up and restore, 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 with metabase-init done and the data kept, and a second up stopped nothing; with AGORANOMOS_IPV6=true, up probed, stopped the stack once and made the network with IPv6 and a Unique Local Address subnet, and the same back with false. A plain docker compose create with 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.