Skip to content

Configuration

Agoranomos reads its settings from the .env file in the root of the checkout. ./agoranomos init creates it from .env.example and fills in every secret that is empty (Command line). Only its owner can read .env, and it is never committed.

Keep a copy of .env

.env holds the keys that decrypt the connections Airflow and Metabase save in their databases. Without them, the connections in a restored backup of those databases cannot be decrypted. Keep a copy in a password manager.

Before an upgrade of Metabase, back up its database as well: Back up Metabase before an upgrade.

./agoranomos reads each setting as Docker Compose does (compose-go 2.16.1, as of October 2026) for lines of the form KEY=value: the last line that sets a setting counts, a value in single or double quotes is what is between the quotes, and a value without quotes ends where a space and # start a comment, without the blanks and the carriage return at its end, which a .env saved with the line endings of Windows has. After an empty value, a space and # do not start a comment: Compose reads KEY= # note as the value # note, and ./agoranomos preflight stops on such a line, so put a comment on a line of its own. Write each setting at the start of its line, without export and without blanks around the =, keep each value on one line, and put a value with spaces, a # or a $ in single quotes: Compose expands $ and reads escapes such as \" outside single quotes, and ./agoranomos does neither.

Docker Compose

Setting Default What it does
COMPOSE_PROJECT_NAME agoranomos The name of the Compose project. It starts the names of the containers, networks and volumes.
COMPOSE_PROFILES core,demo,ai The profiles that run. core is the whole stack in local mode, and demo adds the demo: on the first start, the one-shot demo-init job runs the demo pipeline and creates a demo dashboard in Metabase (See the demo). Set core to start without it. ai adds the container of the MCP servers of the warehouse and of Airflow, in local mode; ./agoranomos ai warehouse\|airflow on\|off adds it and takes it out (The MCP servers of the warehouse and of Airflow). server adds the proxy of server mode, and ops the backup service, which takes a backup every day; ./agoranomos init --server adds both and takes ai out (Server mode, ADR-0019; Backups, ADR-0020).
COMPOSE_FILE compose.yaml The Compose files of the stack. compose.yaml runs Airflow with LocalExecutor, which needs no broker. compose.yaml:compose.celery.yaml adds the Celery broker and a separate worker (CeleryExecutor), for which 12 GB of memory is recommended (System requirements).

Stack

Setting Default What it does
AGORANOMOS_ADMIN_EMAIL admin@example.com The email of the admin account of Metabase and Airflow (user name admin in Airflow), created on the first start. Change it before the first start.
AGORANOMOS_EXTENSIONS_DIR ./extensions The folder for the deployment's own extensions (ADR-0013): its DAGs in dags/, its overrides of the metadata of the marts in metadata/ (below), and the settings of its model in model/ (below). Releases never overwrite it. A relative path starts at the checkout, ~/ at your home folder; preflight stops on a value that is none of these and not absolute, which Compose would read as the name of a volume.
AGORANOMOS_REPORTING_CURRENCY USD The currency in which the marts add up money across ad accounts: the columns that end in _reporting, such as spend_reporting, at the rate of each day (ADR-0025). USD or EUR, the currencies of the exchange rates of the demo, until a source of rates comes with the connectors; preflight stops on another. A change takes effect at the next ./agoranomos up, which gives the services the new value: in the currency of the fields of Metabase at once, and in the marts at the next run of the transform, which the daily load of the demo starts once it has loaded a new day, after 09:17 UTC: to change them sooner, or without the demo, trigger agoranomos_demo_transform in Airflow after the up. Until it has run, Metabase shows the amounts of the old currency with the code of the new one.
AIRFLOW_PORT 8080 The port of the Airflow UI and API on 127.0.0.1.
METABASE_PORT 3000 The port of Metabase on 127.0.0.1.
AIRFLOW_PARALLELISM 4 How many Airflow tasks run at the same time at most, in the whole stack. With LocalExecutor, each one is a process in the scheduler container, so more tasks need more memory; when the container reaches its limit, the kernel stops a task before the scheduler on a host whose kernel restricts ptrace with Yama, such as Ubuntu, and can stop the scheduler on one without it, such as Docker Desktop for macOS (System requirements). With CeleryExecutor it caps all workers together: raise it together with AIRFLOW_WORKER_CONCURRENCY or the number of workers.
AIRFLOW_WORKER_CONCURRENCY 4 Only with CeleryExecutor: how many tasks one Airflow worker runs at a time. More tasks need more memory: raise AIRFLOW_TASKS_MEMORY, the limit of the worker, with it. Each of these processes keeps a connection to the database of the results of Celery, whose role takes at most 20 (Secrets): keep the sum over all workers at 18 or less.

Server mode

Server mode puts Metabase on the internet behind Caddy, with HTTPS, and opens the ports 80 and 443 of the host and no other (ADR-0019). Airflow and Metabase stay on 127.0.0.1 too, for SSH tunnels.

./agoranomos init --server <domain> turns it on: it sets AGORANOMOS_SITE_URL=https://<domain>, adds the server and ops profiles to COMPOSE_PROFILES, and sets AGORANOMOS_IPV6 and the two rules of Metabase below where they are not set (a plain init adds AGORANOMOS_IPV6=true to a .env that lists server and lacks it). To go back to local mode, run ./agoranomos down first, while server is still in COMPOSE_PROFILES: Compose stops only the services of the active profiles, so after you take server out, the proxy container would keep running on ports 80 and 443. Then take server out of COMPOSE_PROFILES (and ops, unless you want the daily backups in local mode too), delete the lines AGORANOMOS_SITE_URL, AGORANOMOS_IPV6, METABASE_PASSWORD_COMPLEXITY and METABASE_SESSION_TIMEOUT of .env, and run ./agoranomos up: Metabase then has its address on localhost again. If you took server or ops out first, ./agoranomos up and ./agoranomos down stop and remove the proxy or backup container that is left, and say so; the volume caddy-data and the backups stay. A .env of server mode from before the backups lists server and not ops: ./agoranomos preflight warns about it, and ./agoranomos init --server <domain> again adds ops.

Setting Default What it does
AGORANOMOS_SITE_URL http://localhost: and METABASE_PORT The address of Metabase, which it uses in its links. In server mode it is https:// and the domain name of the server, which Caddy serves; ./agoranomos init --server <domain> sets it. It is not in .env.example, so that local mode keeps the default.
AGORANOMOS_IPV6 false true or false: whether the network default of the stack, which Airflow, Metabase and the proxy use, has IPv6, with a subnet of Unique Local Addresses that Docker picks (Docker Engine 27.0.1 or newer; server mode needs 28.0 anyway). ./agoranomos init --server sets true, so that Metabase sees the address of each IPv6 client as it sees that of each IPv4 client: without IPv6 on the network, Docker hands the connection of an IPv6 client to the proxy from the gateway of the network, and the limit of Metabase on failed sign-ins from one address counts all IPv6 clients together (ADR-0023). Local mode leaves it false, as Metabase listens on 127.0.0.1 only there. A plain init, which up runs, adds true to a .env that lists server and lacks the setting, and says so. The network carries the label dev.agoranomos.ipv6 with the same value. Compose recreates a network when anything in its definition changes, and then joins the containers that it keeps to it without the aliases of their services, so ./agoranomos up and ./agoranomos restore stop the stack first (docker compose down, never with the volumes) when the network that exists lacks the label, has another value in it, or has the other IPv6 setting, and Docker makes the network and the containers together. That happens once for every existing stack, in local mode too, at the first up after the release that gave the network the label, and again at each change of the setting; the data stays. Before the stop they build and pull the images, and with true they make a network with IPv6 under a name of their own and remove it; if one of these fails, they stop with Docker's error and leave the stack as it is (for the network, false is one way out if the error is about IPv6 or ip6tables). Not in .env.example.
AGORANOMOS_TLS acme Where Caddy gets the certificate of the site: acme, from the ACME directory below, or internal, from its own authority, which no browser trusts, for tests.
AGORANOMOS_ACME_CA https://acme-v02.api.letsencrypt.org/directory The ACME directory of Let's Encrypt. For a trial, its staging directory, https://acme-staging-v02.api.letsencrypt.org/directory, has higher limits and gives certificates that browsers do not trust. Let's Encrypt issues at most five certificates for the same names in seven days (as of October 2026).
METABASE_PASSWORD_COMPLEXITY normal The rule of Metabase for new and changed passwords: normal, 6 characters with a digit, or strong-enough, at least 15 characters, which ./agoranomos init --server sets. With strong-enough, in local mode as well, a METABASE_ADMIN_PASSWORD that you write by hand must have at least 15 characters too, and ./agoranomos preflight stops on a shorter one, also when Metabase is set up already; the one that init generates has 32. metabase-init never changes the password of an admin that exists, so for a Metabase that is set up already, first change the password in Metabase (Account settings) to at least 15 characters (with the stack stopped, docker compose up -d metabase in this folder starts Metabase alone, with the databases it needs and without the proxy, at 127.0.0.1 on METABASE_PORT, which on a server you reach through an SSH tunnel), then put the same value in .env; for a new one, write a longer value, or leave it empty for init to generate one. Like AGORANOMOS_SITE_URL, it is not in .env.example.
METABASE_SESSION_TIMEOUT none How long a person stays signed in to Metabase without activity, as JSON: ./agoranomos init --server sets '{"amount":24,"unit":"hours"}'; the unit is seconds, minutes or hours. Empty, Metabase keeps a session until it expires, after two weeks. Not in .env.example.

Caddy keeps its certificates in the Docker volume caddy-data, which backups leave out: a lost volume means a new certificate. It sends Let's Encrypt no contact email, as Let's Encrypt has sent no expiration emails since June 2025, and Caddy renews the certificate by itself.

Deploy on a server walks through it on a new server.

AI access in Metabase

The level of AI access sets what AI clients may do in Metabase through its MCP server, and what every account may do in the Metabase UI as well (ADR-0017). ./agoranomos ai shows the four settings below, ./agoranomos ai set <level> [--default <level>] writes them together, and ./agoranomos up applies them (Command line). Change them with the command rather than by hand. ./agoranomos preflight, which up runs, stops in these cases and names the command that sets them right:

  • the level is not one that this release offers;
  • the default level is not a level from answers up, or is higher than the level;
  • the two switches do not follow the level.

metabase-init checks the same for a start through plain docker compose, and fails before it changes anything.

A new installation gets the level build and the default level explore, from .env.example. A .env of a release from before the levels gets off from init, which says how to turn AI access on, so an upgrade widens no access. A setting that is not set, or empty, has the default that compose.yaml gives it, which is off for the level.

Setting Default What it does
METABASE_AI_LEVEL build The highest level of AI access that the deployment allows: off, answers, explore, build or build-sql, each of which adds to the one before it. At off the MCP server of Metabase is off. This release offers off, answers, explore and build; build-sql comes after the first release of the AI layer, and until then SQL from AI clients goes through MCP Toolbox (The MCP servers of the warehouse and of Airflow).
METABASE_AI_DEFAULT_LEVEL explore The level that every account gets, from answers up, and no higher than METABASE_AI_LEVEL. It is not used while the level is off.
METABASE_MCP true Follows the level: true from answers up. Metabase gets it as MB_MCP_ENABLED, and as the master switch of its AI features, MB_AI_FEATURES_ENABLED, which the MCP server needs.
METABASE_MCP_SQL false Follows the level: true only at build-sql. Metabase gets it as MB_MCP_EXECUTE_SQL_ENABLED, which turns on the SQL that a client writes.

What each level may do:

Level Queries on the marts Workspace
answers Runs the saved questions and dashboards of the collections that the account may view, and builds no new query No access
explore Also builds new queries on the marts with the query builder, and saves them in the person's own collection No access
build As explore Curate: creates, changes and trashes questions, dashboards, metrics and documents there

Above off, metabase-init sets these permissions on every start, with one line for each change:

  • All Users, the group of every account, gets the default level:
    • Create queries on the warehouse, and at most that on every other database;
    • View on the root collection and on Reports, and at most View on every other collection;
    • on Workspace and its sub-collections, Curate from build up and No access below it.
  • Groups of the setup. Each level above the default, up to the level of the deployment, has a group: Agoranomos: Explorers, Agoranomos: Builders. An admin puts people into them in Metabase, and the setup never changes who is in a group. A group gets the permissions of its level, kept between the default and the level of the deployment when either changes: a group above the level gets the level, and one at or below the default gets the default.
  • Workspace and Reports. Two collections at the root: Workspace for shared drafts, and Reports for finished work, which everyone views and only admins curate. The setup finds them by their entity IDs, not by name, so you may rename or move them; one in the trash comes back.
  • What stays. A group that the deployment made itself keeps its permissions, apart from Curate on Reports, which only admins have. Personal collections keep theirs, and the content of the package stays view-only for everyone but admins (The content of the package).

At off the setup changes no permission, also not those that a higher level set before. ./agoranomos ai diff shows what the next up would change in these permissions, and changes nothing (Command line).

An AI client connects to the MCP server of Metabase with an OAuth client that ./agoranomos ai connect registers, and each person signs in with their own account, which is not an admin (ADR-0031). In server mode the proxy closes registration to the internet and lets a token reach only the MCP server. At off, ./agoranomos up and ./agoranomos ai set off delete every OAuth client, with its tokens, so that no client keeps access; a start with plain docker compose deletes none. ./agoranomos ai clients lists the clients, and ./agoranomos ai revoke deletes one (Command line).

Metabase OSS records which clients people authorized, but not the calls that the clients make. So, after the permissions, metabase-init looks on every start for what the levels do not keep, and ./agoranomos up looks at the OAuth clients in the application database of Metabase. up shows each finding as a warning, which does not stop the start:

  • a subscription of a dashboard, or an alert on a question, that sends to an email address that no account of Metabase has, or to Slack or a webhook. The warning names the subscription or the alert by its ID, and an admin checks it in the menu of the dashboard or of the question;
  • SQL questions that members of the groups of the setup saved, which no level of this release writes and which keep running, by their IDs;
  • above off, a group of the setup without members;
  • above off, an admin account that holds a token of an OAuth client, which has not expired: an AI client works as the account that connected it, so an admin connects with a second account that is not an admin;
  • above off, an account that holds a token with mb:full, the scope of the whole REST API of Metabase, which no client that ai connect registers gets. The warning names the client, and ./agoranomos ai revoke cuts it off.

Above off, the last line of up also names the groups of the setup and how many people each has, and ./agoranomos ai shows the same while Metabase runs.

Backups

./agoranomos backup takes a backup of the three databases at any time, and with the ops profile, which ./agoranomos init --server turns on, the backup service takes one every day (ADR-0020).

Setting Default What it does
AGORANOMOS_BACKUP_DIR ./backups The folder of the backups. The default folder, backups/, is ignored by git; another path is not, unless you ignore it yourself. init creates it, open to its owner only. A relative path starts at the checkout, ~/ at your home folder; preflight stops on a value that is none of these and not absolute, which Compose would read as the name of a volume. By default the folder is on the disk that holds the data of Docker: on a small server put it on another disk, such as /mnt/backups, and ./agoranomos status says how much room the disk has.
BACKUP_TIME 03:00 With the ops profile: the time of the daily backup, as HH:MM. A backup that the stack missed, because it was down, comes at its next start. Choose a time when no pipeline runs: a dump holds a shared lock on each table of its database until it ends, and a change of a table by dbt or dlt waits behind it for at most a minute and then fails. No DAG of the base package runs at 03:00 UTC.
BACKUP_TZ UTC The time zone of BACKUP_TIME, such as Europe/Berlin. preflight checks the name.
BACKUP_KEEP_DAILY 7 How many days back the newest scheduled backup of each day stays; at least 1.
BACKUP_KEEP_WEEKLY 4 How many weeks back, Monday to Sunday, the newest scheduled backup of each week stays.
BACKUP_KEEP_MONTHLY 3 How many months back the newest scheduled backup of each month stays.
AGORANOMOS_UID, AGORANOMOS_GID auto The user and the group that write the backups, so that they belong to you: init replaces auto with yours. preflight, backup and restore stop on 0, root. On Linux they stop too when the backups folder belongs to another user, because the service runs as AGORANOMOS_UID and cannot write into a folder of mode 700 that another user owns: a .env that you move to another machine keeps the IDs of the first, so set them to auto there and run ./agoranomos init.

A backup holds the dumps of the databases, the global objects of both instances without passwords, an archive of the extensions folder, the SHA-256 of each file and a manifest, which records the images of the checkout. It does not hold .env, whose keys a restore needs, or the certificates of server mode. A backup is as sensitive as .env: it holds every row of the warehouse, the hashes of the passwords of the users of Metabase, and the connection details that only the METABASE_ENCRYPTION_KEY of this .env decrypts. ./agoranomos restore puts a backup back (Command line). The days, weeks and months of the retention are those of UTC.

The retention removes scheduled backups only, after each good backup. A backup that ./agoranomos backup took, such as the one before an upgrade, stays until you delete its folder with rm -r backups/<name>; ./agoranomos status lists these backups with their size and age. Take it before you update the checkout with git pull: a backup in a container of its own records the images of the checkout as they are when it runs. When the disk is full, every backup fails and the retention removes none: delete old backups to free space.

A backup is written as .partial-<name> and renamed when it is complete, so a folder with the name of a backup is a complete one. A stop of the backup service during a backup, such as ./agoranomos down, ends it: the next backup removes the partial folder, and latest.json, in the backups folder, keeps the result of the last backup. To turn the daily backups off, take ops out of COMPOSE_PROFILES; the backups stay (Server mode says what up and down do with the backup container that is left).

The backup service runs as AGORANOMOS_UID and AGORANOMOS_GID of the host, so that the backups belong to you. Rootless Docker and userns-remap, which map the users of containers to other users of the host, are not supported: the backups would belong to another user.

Back up and restore shows how to schedule, copy and restore them.

The MCP servers of the warehouse and of Airflow

Two more MCP servers run in local mode, for an AI client on the same machine, such as Claude Code in the checkout (ADR-0030):

  • The warehouse, MCP Toolbox for Databases 1.15.0. It signs in as the role ai_reader, which reads marts and staging and nothing else, in sessions that start read-only, with a statement timeout of one minute and at most three connections (Secrets). Its tools are in ai/tools.yaml: list_tables and describe_table, which list the tables and views that the role may read and their columns, with the descriptions of the dbt project, and execute_sql, which runs one statement.
  • Airflow, astro-airflow-mcp 0.9.1, in its read-only mode, which refuses every tool that writes, such as trigger_dag or pause_dag, before the call reaches Airflow. It signs in as the Airflow user ai, which has the role Viewer alone: it reads the DAGs, their runs and the logs of their tasks, and gets no connections, variables or configuration of Airflow. airflow-init gives the user that role and the password of .env on every start, so a role or a password changed in the Airflow UI goes back.

Both run in one container, ai, of the ai profile, which opens no port and reaches only warehouse-db and the API of Airflow, with no route to the internet. An AI client starts a server with ./agoranomos mcp warehouse or ./agoranomos mcp airflow, which speaks MCP on its standard input and output, and .mcp.json in the checkout gives Claude Code both (Command line). The container holds the passwords of ai_reader and of the user ai, and no other. Its memory limit, AI_MEMORY, fits one session of a client at a time: a second session at the same moment can reach it, and the kernel then stops a process of the container and of no other service. Toolbox holds the whole answer of a query in memory, so a statement that returns a large table can reach the limit as well.

Each server has two limits that do not depend on each other: its switch, and the account behind it. While a switch is off, its server does not start, ai_reader cannot sign in, and the Airflow user ai is deleted.

Setting Default What it does
WAREHOUSE_MCP true true lets the MCP server of the warehouse start, and ai_reader sign in.
AIRFLOW_MCP true true lets the MCP server of Airflow start, and keeps the Airflow user ai.

./agoranomos ai warehouse on or off, and ./agoranomos ai airflow on or off, set a switch and keep the ai profile listed while a switch is on, and ./agoranomos up applies it (Command line). A new installation gets both on in local mode. A .env of a release from before them gets both off from init, and its COMPOSE_PROFILES stays as it was, so an upgrade widens no access. ./agoranomos init --server turns both off and takes ai out: in server mode AI clients reach Metabase over HTTPS (AI access in Metabase). ./agoranomos preflight, which up runs, stops when a switch is not true or false, when server mode lists ai or has a switch on, and when ai is listed while both switches are off. A switch that is on while COMPOSE_PROFILES does not list ai, as with COMPOSE_PROFILES=core, runs no server, and ./agoranomos ai shows it as off.

Memory limits

Every service has a memory limit, also for swap, so that the stack stays within the memory of System requirements and, on the recommended memory (8 GB, or 12 GB for the CeleryExecutor variant), a service that runs short of memory cannot take it from the others (System requirements says what holds on the minimum of 6 GB). The limits of the services that run all the time are these settings, with defaults from measurements on the demo; those of the one-shot setup jobs are fixed in compose.yaml. A value is a number of MiB with m, or of GiB with g: preflight stops on a number without a unit, which Compose would read as bytes, and on a size below 6 MiB, the smallest limit that Docker accepts.

Setting Default Service
WAREHOUSE_DB_MEMORY 384m warehouse-db, the warehouse
PLATFORM_DB_MEMORY 512m platform-db, the databases of Airflow and Metabase
AIRFLOW_APISERVER_MEMORY 512m airflow-apiserver
AIRFLOW_DAG_PROCESSOR_MEMORY 512m airflow-dag-processor, which renders the transform DAG from the manifest of dbt each time that it parses it (below)
AIRFLOW_TRIGGERER_MEMORY 512m airflow-triggerer
AIRFLOW_TASKS_MEMORY 1280m The service that runs the Airflow tasks: airflow-scheduler with LocalExecutor, airflow-worker with CeleryExecutor
METABASE_MEMORY 2176m metabase; half of it is the heap of Java
PROXY_MEMORY 256m Server mode only: proxy, the Caddy in front of Metabase
BACKUP_MEMORY 256m With the ops profile: backup, the daily backups
AI_MEMORY 384m With the ai profile, in local mode: ai, the MCP servers of the warehouse and of Airflow, for one session of a client
AIRFLOW_SCHEDULER_MEMORY 640m Only with CeleryExecutor: airflow-scheduler, which then runs no task
BROKER_MEMORY 256m Only with CeleryExecutor: broker

When a service needs more, raise its limit in .env and run ./agoranomos up, which starts it again with the new limit. A one-shot job has no setting here: System requirements says how one that reaches its limit shows, and what to do. The tasks of heavier pipelines need more than the demo, and so do more tasks at the same time: with LocalExecutor, up to AIRFLOW_PARALLELISM tasks run in the scheduler at once, so raise AIRFLOW_TASKS_MEMORY with it. Each limit that you raise needs memory on the machine as well: System requirements says how the defaults add up. When a service reaches its limit, the kernel stops one of its processes (System requirements).

Settings fixed in compose.yaml

Some settings are not in .env, because changing them is a decision and not a preference. They are environment variables in compose.yaml, and a value from an environment variable wins over any value saved in the application.

The AI switches of Metabase are environment variables in compose.yaml, so that nobody changes them in Metabase, admins included.

  • The MCP server and SQL. The MCP server, with the registration of its OAuth clients and the master switch MB_AI_FEATURES_ENABLED that it needs, follows the level of AI access in .env (AI access in Metabase), and so does the SQL that a client writes, MB_MCP_EXECUTE_SQL_ENABLED.
  • Always off. MB_AGENT_API_ENABLED, MB_METABOT_ENABLED and MB_EMBEDDED_METABOT_ENABLED are false at every level, which keeps the Agent API and Metabot off (ADR-0031).
  • Admin > AI. The switch there has no effect while the variables are set. Changing it saves a value that would apply if a variable were removed, so leave it alone.

The switches do not cover the keys of LLM providers, which Metabase uses for its own AI features, such as Metabot and the generation of SQL. Metabase 0.64 keeps them in a list of provider connections, to which an admin can add, in Admin > AI and whatever the switches say, a connection with the key of one of eleven providers, such as Anthropic, OpenAI or Google. An Anthropic key also opens /api/llm/generate-sql, which does not read the master switch, as in Metabase 0.63. So compose.yaml also sets MB_LLM_PROVIDERS to an empty list, []: the list then comes from the environment, and Metabase refuses to change it, in Admin > AI and through its provider API, with a message that names the variable (checked on Metabase 0.64.1, as of October 2026). The lock covers the provider API only. The older setting of each provider, such as llm-anthropic-api-key or llm-openai-api-key (PUT /api/setting/…), still saves a value into the stored list, which has no effect while the variable is set. While the variable is set, Metabase neither shows that stored list nor lets anyone clear it through its API, and the list applies as soon as the variable is removed: a change that removes MB_LLM_PROVIDERS should check Admin > AI at the first start without it, which is when that list shows. A variable of one provider, such as MB_LLM_ANTHROPIC_API_KEY, would still add a connection; compose.yaml sets none, and the stack tests check that the list is empty.

Metabase keeps the addresses of its users at home too. On a sign-in from a new device, Metabase 0.63 and 0.64 send the IP address of the sign-in to get.geojs.io, a service on the internet, to name its place in an email to the user. MB_SEND_EMAIL_ON_FIRST_LOGIN_FROM_NEW_DEVICE=false turns that email off, and the question with it. The stack sets up no email server, so no email is lost; if you set up email in Metabase, it still sends no email about a sign-in from a new device, because this setting can be changed only from the environment. The login history in Account settings still asks where each address is, so compose.yaml also gives the container of Metabase the name get.geojs.io for its own address, where nothing listens: the question reaches no one, the login history shows no place, and the log of Metabase has an ERROR line, Error geocoding IP addresses: Connection refused, each time that someone opens the login history (checked on Metabase 0.64.1, as of October 2026). MB_AI_EVAL_CAPTURE=false keeps off a feature of Metabase 0.64 that would write the traces of its AI features, prompts and answers with the data that they carry, into files for an admin to read; it is off by default, and it can be turned on only from the environment.

dlt, dbt and Cosmos send anonymous usage statistics by default, and four variables in compose.yaml, which the Airflow image sets too, turn that off: RUNTIME__DLTHUB_TELEMETRY=false for dlt, DBT_ENGINE_SEND_ANONYMOUS_USAGE_STATS=False for dbt, AIRFLOW__COSMOS__ENABLE_TELEMETRY=False for Cosmos, and DO_NOT_TRACK=1, which dbt and Cosmos also honor. The dbt project says the same with send_anonymous_usage_stats: false in dbt/dbt_project.yml. Metabase reports nothing to its makers either: MB_ANON_TRACKING_ENABLED and MB_CHECK_FOR_UPDATES are false.

A connection of Metabase to a database may go to a private network, where the warehouse is, or to a public address on the internet, but not to a loopback or link-local address, such as the metadata service of a cloud at 169.254.169.254, or to another address that is neither private nor public, such as the documentation range 192.0.2.0/24 (checked on Metabase 0.64.1, as of October 2026): MB_WAREHOUSE_ALLOWED_NETWORKS=allow-private, which also covers the SSH tunnels of connections and the URLs of their authentication providers. Metabase reaches the internet only for what an admin sets up, such as email or Slack, which Agoranomos does not set up (ADR-0021). In server mode the proxy closes the two endpoints of Metabase that forward requests to the servers of Metabase without a sign-in, /api/analytics-proxy and /api/product-feedback.

In server mode the proxy also keeps the OAuth server of Metabase to its MCP server (ADR-0031):

  • Registration. It answers 404 to every path that begins with /oauth/register, where any client could register itself with no sign-in, also with mb:full, the scope that reaches the whole REST API of Metabase.
  • Tokens. It refuses with 403 a request that carries an Authorization header on every path but /api/mcp, so that an OAuth token reaches the MCP server and nothing else of Metabase. The UI of Metabase signs in with a cookie and sends no such header.
  • Clients with a secret. A client that sends its secret in that header to the token endpoint (client_secret_basic) gets no token through the proxy. The clients that ./agoranomos ai connect is to register have no secret.

dlt reads no settings or secrets from Airflow Variables: PROVIDERS__ENABLE_AIRFLOW_SECRETS=false is set in compose.yaml, and in the Airflow image too. Inside an Airflow task, dlt would otherwise also take its settings from Variables, and a person who may edit them, such as a user with the Op role of Airflow, could redirect a pipeline: for example, send the connection of a load, with its password, to another host. So a DAG in the extensions folder (ADR-0013) that follows dlt's guide for Airflow cannot keep its settings in a dlt_secrets_toml Variable, which dlt ignores here: the pipeline takes them from environment variables instead.

The two caches from which Cosmos renders the tasks of dbt stay out of Airflow Variables: AIRFLOW__COSMOS__ENABLE_CACHE_DBT_LS=False and AIRFLOW__COSMOS__ENABLE_CACHE_DBT_YAML_SELECTORS=False are set in compose.yaml, and in the Airflow image too. By default Cosmos saves the output of dbt ls, from which it renders the tasks of dbt and their arguments, in a Variable named cosmos_cache__<DAG>__<task group>, and the YAML selectors of a dbt project the same way: a person who may edit Variables could then change what a dbt task runs. A cosmos_cache__ Variable that was saved before is no longer read, and you can delete it.

Cosmos does not run dbt ls either: it renders the tasks of the transform DAG from the manifest of the dbt project, which the one-shot job metadata-export writes on every start into the volume dbt-manifest, which the Airflow services mount read-only (ADR-0027). So a change of the dbt project, such as a new model, reaches Airflow at the next ./agoranomos up, and a dbt project that dbt cannot read stops the start: ./agoranomos logs metadata-export shows why. The DAG processor parses a DAG file that has not changed again every five minutes, where Airflow's default is every 30 seconds: compose.yaml sets AIRFLOW__DAG_PROCESSOR__MIN_FILE_PROCESS_INTERVAL=300. A new DAG file in the extensions folder shows up within about a minute, as the DAG processor looks for new files every minute (AIRFLOW__DAG_PROCESSOR__REFRESH_INTERVAL=60), and a change to a DAG file within seconds, since Airflow parses a file again as soon as it has changed since its last parse (Airflow 3.3.2, as of October 2026). To have a DAG parsed sooner, choose Reparse Dag at the top of its page in Airflow, or call PUT /api/v2/parseDagFile/{file_token} with the file_token that GET /api/v2/dags/{dag_id} returns.

The tasks of dbt, one for each model and each seed, which builds it and runs its tests (ADR-0028), run in the pool dbt of Airflow, at most two at a time, whatever AIRFLOW_PARALLELISM allows: each is a process of dbt of its own, and they share the memory of the container that runs them: the scheduler with the LocalExecutor, the worker with the CeleryExecutor. airflow-init sets the pool on every start, so an edit of its size in the Airflow UI does not last. It sets the pool demo_load the same way: the loads of agoranomos_demo_history and agoranomos_demo_daily run in it one at a time, so that two loads never replace the days of a platform at the same time.

A DAG in the extensions folder should not use two features of Cosmos that still keep state in Variables, and that the stack does not use: SeedRenderingBehavior.WHEN_SEED_CHANGES, which keeps cosmos_seed_checksum__… Variables, and the watcher execution mode, which keeps a backup of its XComs there.

Metabase can give a question or a dashboard a public link, which anyone who has it opens without signing in. Metabase 0.64 allows them by default; metabase-init turns them off once, when it sets up a new Metabase, and an admin may turn them on again in Admin > Settings > Public sharing. A public link shows its question or dashboard with the values of the filters in its address, and whoever has the link can take them out of the address and see what the filters hid. A Metabase set up before this release keeps the setting that it had, so before you turn server mode on for one, check Admin > Settings > Public sharing. metabase-init tries three times, two seconds apart, at the first start: if all three fail, its error, which ./agoranomos logs metabase-init shows (./agoranomos up does not print it), says that the links are still on and where to turn them off, and no later start tries again, so turn them off in Admin > Settings > Public sharing. The same holds if metabase-init stops between creating the admin and turning the links off, for any reason, such as a lost answer to the setup call: a later start finds the admin, does not try again and prints no error, so check Admin > Settings > Public sharing.

The metadata of the marts

The display names, descriptions and semantic types of the tables and fields of the marts in Metabase come from the dbt project (Describe the marts). On every start, the one-shot job metadata-export exports them from dbt, and metabase-init applies them, with the overrides of this deployment; demo-init applies them too, after its sync (ADR-0016).

The overrides are in metadata/overrides.toml in the extensions folder, which releases never change, and the file is optional. Each entry names a table of the marts, [marts.<table>], or a column of one, [marts.<table>.columns.<column>], with any of three properties (Rename a field or add your own definitions):

Property What it does At most
display_name Replaces the display name of the release. One line. 100 characters
description Replaces the description of the release. 2,000 characters
note Follows the description of the release after an empty line, so that a better description of a later release still reaches Metabase. 2,000 characters
[marts.ad_stats_campaign_daily.columns.conversions]
display_name = "Purchases"
note = "In this business, a conversion is a paid order in the online shop."

Each value is a text in quotes, and the file has at most 256 KiB. A table, a column or a property that dbt does not have, a value that is not a text or is empty, two columns of a table with one display name, and a mistake in the TOML are errors: the job then writes nothing in Metabase and stops with one line that names the entry, and ./agoranomos up fails. The file is data: the jobs parse it, and nothing in it runs. Metabase OSS shows one display name for each field to everyone, so names in another language are for the whole deployment.

The code always wins. A display name, a description or a semantic type that someone changes in the Metabase UI is set back at the next start, and each change is one line of ./agoranomos logs metabase-init, and on the first start with the demo also of ./agoranomos logs demo-init, which applies the metadata of the marts of the demo once the demo pipeline has built them: a first fill, an update from the code, or the revert of an edit, with the value that it replaced. A line is written as soon as its change is made, so the log keeps it also when a later step of the job fails. What the code does not declare, such as whether a field is hidden, stays as it is. ./agoranomos metadata diff prints what the next start would change, and changes nothing (Command line).

A mart, or a column that a release adds or renames, reaches the warehouse with the run of dbt that builds it, which can come after a start. Until the next start, Metabase shows it with its own name and type, and with the description of its comment from its next sync; the next start syncs it and applies its metadata. Until dbt has built it, the log of metabase-init has a note that says so.

The content of the package

The content that Agoranomos ships in Metabase is in the collection Agoranomos at the root, with a collection in it for each package of the content: today Demo, which demo-init makes with the demo profile (See the demo). The collection Agoranomos demo of v0.1, the one whose dashboard the account of demo-init made, moves into it as Demo at the first start of v0.2.0, once, after everything in it that the demo does not ship, the trash included, has gone to the root (See the demo). As with the metadata of the marts, the code wins (ADR-0018):

  • View only. On every start, metabase-init, and demo-init after it, give every group but Administrators at most View on Agoranomos and on every collection in it, at any depth, the groups that a deployment made itself included: Curate becomes View, with one line of the log for each permission that a job lowers, such as lowered: group "All Users" on collection "Demo": curate -> view, and No access stays. A job locks each collection that it makes right after Metabase made it, as a new collection takes the permissions of its parent, and All Users may curate the root of a new Metabase; should anything reach it before the lock, the job puts the collection in the trash with it and stops with one line, and the next start makes it again. So only an admin can put anything into Agoranomos. When the permissions change twice while a job lowers them, it stops with one line rather than leave the content unlocked.
  • Put back as the code says. Each start of demo-init compares each item of the demo with the code: the names and descriptions of the collection, the questions and the dashboards, where each of them is, the type and the whole query of each question, with the template tags of its filters, all its settings of display, its parameters (none: Metabase shows its filters from the template tags), the dashboard that it belongs to (none) and the metadata of the columns of its answer, which must be exactly what Metabase makes for the columns that the code declares: what it computes from the query, or what it records after a run of the question, with the keys and the statistics of the data that it adds then; an edit there could give a column another name, display name or description, and each card of a dashboard whole, with its question, its place, its settings of display, such as a click behavior, its filter mappings and its series, and the filters, with their defaults, and the tabs of the dashboard. It puts back what differs, a setting that someone added included, with one line for each change, such as revert of an edit: question demo/spend-by-day name: "My spend" -> "Spend by day and platform". An item in the trash comes back to its place (restored from the trash), and one that was deleted for good is created again (created again). Metabase keeps the version that a job replaced in the history of revisions of the item.
  • What is retired. An item that a release no longer ships goes to the trash, with a line that starts with retired, and leaves the registry. An admin can restore it from the trash and move it out of Agoranomos.
  • Admins. Metabase lets an admin change everything, so an admin's edit holds until the next start, which puts it back and logs it. To keep a variant, copy a dashboard, with copies of its questions, into a collection of your own.
  • What the jobs leave. They change only the items of the package. Anything else that an admin puts into Agoranomos or into the collection of a package stays as it is, and a collection or a question of the same name that someone else made is never taken for one of the package. The one move of v0.1 takes a collection of the old name only with the dashboard that the account of demo-init made, and only its own questions of the names of v0.1.
  • The registry. The jobs find the items of the package by the entity IDs that Metabase gave them. Metabase 0.64.1 lets no caller of its API choose an entity ID, so the jobs keep the entity ID of each item of the package, by the key of the item in the code, in a registry. It is the file /var/tmp/agoranomos-content.json on the volume metabase-content, which only metabase-init and demo-init mount, and CONTENT_REGISTRY, which names it, is fixed in compose.yaml. The file holds keys, models and entity IDs, and no secret. A file there that is not a registry of the content stops both jobs before they write anything, with one line that says to move it away; the next start then creates the content again next to the old one. A backup does not hold the registry: Restore on a new server says what that means.

The settings of the model

The model of advertising statistics reads two settings files of the deployment, in model/ in the extensions folder, which releases never change (The data model). Both are optional.

File What it holds
clients.toml The clients of the business, [clients.<id>] with a name, and the ad accounts of each, [[accounts]] with platform, account_id and client (ADR-0024). An account that the file does not name belongs to the client unassigned.
conversion_actions.toml Which conversion actions count in the conversions of a campaign: [[actions]] with platform, conversion_action_id, is_primary and, optionally, conversion_category; an entry sets whether the action is primary, and its category when it names one, in place of the row of the base package for the same action (ADR-0025).

The first task of each run of agoranomos_demo_transform, load_settings, reads both files, with the clients of the demo from a file of the same form in the base package, checks them and loads them as the loader role into raw_settings, in place of what was there; the dbt run that follows uses them. So a change takes effect at the next run of the transform, which you can trigger in Airflow, and needs no ./agoranomos up. The Airflow services mount the folder read-only.

Each file is UTF-8 TOML of at most 256 KiB. A client ID is lower-case letters, digits, - and _, at most 64 characters, starting with a letter or a digit, and unassigned is reserved; a name is one line of at most 100 characters, and an account ID or an action ID at most 200. A key that the file does not allow, an account or an action listed twice, an account of a client that the file does not name, a text over its limit or with control characters, and a mistake in the TOML are errors: the task fails with one line that names the file and the entry, such as clients.toml: [clients.Acme]: a client ID is lower-case letters, digits, - and _, at most 64 characters, nothing is written, no task of dbt runs, and the marts keep their data. The line names a key or an ID of the file only where it says where the mistake is, and repeats no value. The files are data: the task parses them, and nothing in them runs.

Secrets

./agoranomos init generates each of these when it is empty: random letters and digits, and a Fernet key for Airflow.

Setting What it is Changing it later
WAREHOUSE_POSTGRES_PASSWORD The superuser of the warehouse PostgreSQL It is set only when the database is first created. To change it, change it in PostgreSQL too.
PLATFORM_POSTGRES_PASSWORD The superuser of the platform PostgreSQL As for WAREHOUSE_POSTGRES_PASSWORD.
AIRFLOW_DB_PASSWORD The role that owns the metadata database of Airflow Change it and run ./agoranomos up: the role gets the new password.
METABASE_DB_PASSWORD The role that owns the application database of Metabase As for AIRFLOW_DB_PASSWORD.
METABASE_READER_PASSWORD The read-only role that Metabase reads the warehouse as As for AIRFLOW_DB_PASSWORD; Metabase's connection is updated too.
WAREHOUSE_LOADER_PASSWORD The role for loading data into the raw schemas of the warehouse (dlt) As for AIRFLOW_DB_PASSWORD.
WAREHOUSE_TRANSFORMER_PASSWORD The role for building the staging and marts schemas of the warehouse (dbt) As for AIRFLOW_DB_PASSWORD.
BACKUP_DB_PASSWORD The role that takes the backups, on both instances: it reads every table and writes nothing (ADR-0020) As for AIRFLOW_DB_PASSWORD.
VALKEY_PASSWORD The password of the Celery broker, used only with CeleryExecutor Change it and run ./agoranomos up.
AI_READER_PASSWORD The read-only role that the MCP server of the warehouse, MCP Toolbox, reads the warehouse as (The MCP servers of the warehouse and of Airflow) As for AIRFLOW_DB_PASSWORD.
AIRFLOW_AI_PASSWORD The password of the Airflow user ai, with the role Viewer, as which the MCP server of Airflow signs in Change it and run ./agoranomos up: airflow-init gives the user the new password.
CELERY_RESULTS_DB_PASSWORD The role that owns the database of the results of Celery, used only with CeleryExecutor: the worker writes the state of each task there, and holds no password of the metadata database of Airflow As for AIRFLOW_DB_PASSWORD.
AIRFLOW_FERNET_KEY The key that encrypts the connections and variables saved in Airflow Do not change it: what Airflow saved could no longer be decrypted.
AIRFLOW_JWT_SECRET The key that signs the API tokens of Airflow Change it and run ./agoranomos up; people have to sign in again.
AIRFLOW_API_SECRET_KEY The secret key of the Airflow API server As for AIRFLOW_JWT_SECRET.
AIRFLOW_ADMIN_PASSWORD The password of the Airflow user admin, set on the first start With the demo profile, demo-init signs in with it on every start. If you change the password in Airflow, put the same value here.
METABASE_ENCRYPTION_KEY The key that encrypts the connection details saved in Metabase Do not change it: Metabase could no longer read its saved connections.
METABASE_SESSION_KEY The key that signs the sessions that Metabase keeps in its application database, so that whoever can read or write that database cannot make a session of their own. At least 16 characters: Metabase does not start with a shorter one Change it and run ./agoranomos up: everyone has to sign in to Metabase again. The first start of a release that adds it signs everyone out once, the same way.
METABASE_ADMIN_PASSWORD The password of the Metabase admin account The first-start setup signs in with it on every start, and so does demo-init. If you change the password in Metabase, put the same value here.

On every start, the setup job db-init sets each role of the warehouse (loader, transformer, metabase_reader and ai_reader, which is NOLOGIN while WAREHOUSE_MCP is off), of the platform instance (airflow, metabase and celery_results), and backup, which both instances have, to LOGIN, with no SUPERUSER, CREATEDB, CREATEROLE, REPLICATION or BYPASSRLS attribute, and gives it the password from .env. So if you give one of these roles such a power by hand, the next ./agoranomos up takes it back. On both instances, no role but the superuser can connect to the postgres and template1 databases or read the statistics of the statements (pg_stat_statements): airflow, metabase and celery_results connect only to the database that each owns, and the roles of the warehouse only to warehouse. backup is a member of pg_read_all_data; on the warehouse instance (warehouse-db) it connects to warehouse only, and on the platform instance (platform-db) to airflow and metabase only, in sessions that start read-only. It reads no statistics of the statements either: pg_read_all_data lets it select from the views of pg_stat_statements, but the functions behind them stay closed to it. db-init also gives loader and transformer a lock timeout of one minute: a statement that waits longer for a lock fails with canceling statement due to lock timeout. A load then fails and is tried again instead of waiting without end. A dbt run fails too, and the mart keeps its data: dbt renames a table that it rebuilds, which waits for the queries that read the table, and the queries that come later, such as those of Metabase, wait behind it.

db-init gives ai_reader sessions that start read-only, a statement timeout of one minute, the same timeout for a transaction left open, and a limit of three connections; it reads marts and staging, the views of which read the raw schemas as their owner, transformer, and neither a raw schema nor ops. db-init also gives metabase_reader a statement timeout of five minutes and a limit of 20 connections. A question or a dashboard card whose query runs longer fails with canceling statement due to statement timeout, before the query timeout of Metabase itself, 20 minutes; and Metabase, whose pool holds up to 15 connections to the warehouse, cannot take the connections that the pipelines need (both defaults of Metabase 0.64.1, as of October 2026).

celery_results, which only the CeleryExecutor variant uses, owns the database of the results of Celery on the platform instance: the worker writes the state of each task there, and the scheduler reads it, so the worker holds neither the password of the metadata database of Airflow nor the key that signs its tokens. That narrows what the worker holds, but does not keep the code of a task away from the scheduler, which loads the results as pickles (ADR-0015 says how). The code of a task can read the password of the role, so db-init gives it a limit of 20 connections, which keeps such code from taking the connections of the instance from Airflow and Metabase. Each process of a worker that has run a task keeps one of them open: AIRFLOW_WORKER_CONCURRENCY processes per worker; the scheduler opens one more when it reads, and so does db-init. On every start, db-init also deletes the results older than 90 days, which nothing in Airflow removes from that database. It does so as celery_results, never as the superuser, since whoever holds the password of the role decides what runs in its database; when the deletion fails, db-init says so and the start goes on.

AIRFLOW_DB_PASSWORD, VALKEY_PASSWORD and CELERY_RESULTS_DB_PASSWORD are written into connection URLs. If you choose them yourself instead of letting init generate them, use letters and digits only: a character such as @, / or : breaks the URL.

Back up Metabase before an upgrade

A release that moves Metabase to a new major version, such as from 0.63 to 0.64, migrates the application database of Metabase, the metabase database of platform-db, at the first ./agoranomos up. Do not count on undoing the migration: as of October 2026, Metabase 0.63.19 refuses to start on a database that 0.64.1 has migrated, and after the migrate down command of 0.64.1 it fails on a migration of its own. The way back is a backup made before the upgrade. A large application database can take a while to migrate, because 0.64 changes the primary key of the query_execution table, which logs the queries that Metabase runs, and splits that table into partitions.

Before the first ./agoranomos up of the new release, while the stack of the release that you come from runs, note the commit that you come from, which git rev-parse HEAD prints, and take a backup:

./agoranomos backup

It prints the name of the backup, such as 20261011T030012Z, which ./agoranomos status shows too (Command line). Later backups, scheduled or by hand, do not remove it: the retention removes scheduled backups only. Then update the checkout and run ./agoranomos up, which starts the new version; ./agoranomos logs metabase shows the migration.

To go back, return the checkout to the commit that you came from, which pins the earlier version of Metabase, and restore the database of Metabase from the backup. Airflow migrates its database one way too, so see first whether the update also changed the Airflow image, while the checkout is still at the new release:

git diff <the commit that you came from> HEAD -- airflow/Dockerfile | grep '^[-+]FROM'

It prints the old and the new FROM line of the Airflow image when Airflow changed, and nothing otherwise. When it prints nothing, restore the database of Metabase alone. When it prints the lines, restore metabase,airflow, or airflow-init fails on the schema of the newer Airflow; you lose the runs of Airflow since the backup. Return the checkout, then run:

./agoranomos restore 20261011T030012Z --only metabase
# when the Airflow image changed as well:
./agoranomos restore 20261011T030012Z --only metabase,airflow

restore checks the backup before it changes anything: every file against its SHA-256, and each dump that it restores read to its end. A check of the table of contents alone, with pg_restore --list, passes a dump that was cut short after it, and a restore from such a dump would drop the database and leave it without its rows. Then restore stops the services that use the databases, restores the database with its owner and its access rights, and starts the stack (Command line). Everything that Metabase saved after the backup is lost. If restore stops because the images of the backup differ from those of the checkout, the checkout is not at the commit that you came from.