# Can I self-host Equals?

**YES** — it's called Metabase. ONE EVENING setup · ~1.5 hours to running · 4 GB RAM minimum · $2,000/mo you stop paying ($24,000/yr on the Essential plan).

Metabase authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/equals/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Metabase 0.63.2 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server, and it becomes `MB_SITE_URL` in step 3.

Say one thing to the user first. Metabase runs on the JVM, which takes roughly a quarter of the
memory it can see as its heap ceiling: on a 2 GB box that is a 512 MB heap, and the bill arrives
later as a container that dies during somebody's third question. Upstream's own Azure guide asks
for at least 3.5 GB, so this install wants 4096 MB of RAM available and 10 GB free on /srv. Both
images publish amd64 and arm64.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 4096 MB or free disk is under 10 GB, print both numbers and stop. If
`dig +short` prints nothing, print that and stop: Caddy cannot certify a name that does not
resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/metabase /srv/metabase/backups
sudo install -d -m 700 /srv/metabase/postgres
ls -la /srv/metabase
```

Assert: `ls -la` shows `backups` owned by the login user and `postgres` at mode `700` owned by
root, which the PostgreSQL image chowns to itself on first start. Metabase gets no directory: it
drops to uid 2000 and writes only `/plugins`, where it re-extracts the bundled Sample Database on
every start. Nothing there needs to survive, because every account, question, dashboard and
connection detail is a row in PostgreSQL.

## 3. Secrets

Two secrets, both generated on the server. `MB_DB_PASS` is the PostgreSQL password.
`MB_ENCRYPTION_SECRET_KEY` is the key Metabase encrypts stored connection details with, and the
command below is upstream's own instruction for making one. Do not print either, in your summary
or in any log line. `MB_SITE_URL` shares the file and is not a secret: it is the address Metabase
builds links from, with no trailing slash.

```bash
umask 077
cat > /srv/metabase/.env <<EOF
MB_SITE_URL=https://<DOMAIN>
MB_DB_PASS=$(openssl rand -hex 32)
MB_ENCRYPTION_SECRET_KEY=$(openssl rand -base64 32)
EOF
chmod 600 /srv/metabase/.env
umask 022
ls -l /srv/metabase/.env
```

Assert: mode `-rw-------`. Tell the user the key is in /srv/metabase/.env, that they read it with
`sudo grep MB_ENCRYPTION_SECRET_KEY /srv/metabase/.env`, and what upstream says about losing it:
connection details in a restored database cannot be decrypted without it. It goes in their
password manager today, somewhere other than where the backups land.

## 4. compose.yml

```bash
cat > /srv/metabase/compose.yml <<'EOF'
# Metabase · the deterministic fallback. Authored by caniselfhostit from the
# upstream docs and source at the pinned tag:
#   docker + app db .... https://github.com/metabase/metabase/blob/v0.63.2/docs/installation-and-operation/running-metabase-on-docker.md
#   variables .......... https://github.com/metabase/metabase/blob/v0.63.2/docs/configuring-metabase/environment-variables.md
#
# Two services: Metabase, and the PostgreSQL holding its own data, the
# accounts, questions, dashboards and encrypted connection details. What you
# analyse stays in the databases you connect later, read over the network.
#
# Digests read from Docker Hub on 2026-08-14; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.6-alpine@sha256:432b3b824c0769275ec9b0947736ef8b376d6997bcaa9de29818f613819c2feb
    container_name: metabase-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: metabase
      POSTGRES_USER: metabase
      POSTGRES_PASSWORD: ${MB_DB_PASS}
    volumes:
      # The 18 image puts the cluster one level down, so mount the parent.
      - /srv/metabase/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U metabase -d metabase"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  metabase:
    image: metabase/metabase:v0.63.2@sha256:252f8c9bd56dd21158005675b55876cf9fb838e0a0e0541581af859eafe1f32e
    container_name: metabase
    restart: unless-stopped
    # MB_SITE_URL, MB_DB_PASS and MB_ENCRYPTION_SECRET_KEY live here, mode 600.
    env_file: /srv/metabase/.env
    environment:
      # Without this the image writes an H2 file no mount here catches.
      MB_DB_TYPE: postgres
      MB_DB_HOST: postgres
      MB_DB_PORT: "5432"
      MB_DB_DBNAME: metabase
      MB_DB_USER: metabase
      # Default is true; an env var outranks what the wizard writes.
      MB_ANON_TRACKING_ENABLED: "false"
      # Every report and every scheduled hour is read in this zone.
      JAVA_TIMEZONE: UTC
    healthcheck:
      # Upstream's own: 503 with a progress number while migrations run.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://localhost:3000/api/health"]
      interval: 15s
      timeout: 10s
      retries: 20
      start_period: 120s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8210.
      - "127.0.0.1:8210:3000"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/metabase && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-metabase
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Metabase · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/metabase/metabase/blob/v0.63.2/src/metabase/server/middleware/security.clj
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also MB_SITE_URL in .env, which is
# where Metabase builds its links from. No security headers here: Metabase
# already sends HSTS, a CSP with frame-ancestors, X-Frame-Options DENY and
# nosniff of its own.

<DOMAIN> {
	encode zstd gzip

	header {
		-Server
	}

	# 8210 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8210
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-metabase, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it itself.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a Prompt Zero box:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge, 443/tcp is the only way in, 443/udp is HTTP/3. 8210 stays
closed because compose binds it to 127.0.0.1, 5432 because compose never publishes it. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule
mentioning 8210, 5432 or 3000.

## 7. Start and verify

The first boot runs the whole migration set against an empty PostgreSQL and then extracts the
Sample Database, so it takes minutes rather than seconds. Use the loop.

```bash
cd /srv/metabase
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health; echo
curl -sSL https://<DOMAIN>/ | grep -c '<title>Metabase</title>'
curl -sS https://<DOMAIN>/api/session/properties | grep -oE '"(has-user-setup":[a-z]*|setup-token":")'
```

Assert all four and print what you received. The loop ends printing `200`. The health call prints
`{"status":"ok"}`, which upstream returns only once start-up is complete and the application
database answers. The third prints a number above `0`, that title being what Metabase renders
into its own page. The fourth prints `"has-user-setup":false` and `"setup-token":"`,
and that pair is the open door: Metabase mints a setup token on first
launch and publishes it as a public setting, so anyone loading this hostname can post it to
`/api/setup` and become the administrator here.

If any of the four misses, stop, run `docker compose logs --tail 40 metabase`, then
`docker compose logs --tail 20 postgres`, and name the likely earlier step. A `503` reading
`{"status":"initializing"` is this step unfinished, not a failure. A container that exits on a
database error points at step 3. A running container is not success.

STOP: tell the user to open https://<DOMAIN>/setup, complete the wizard that creates their
administrator account, and wait. Do not continue until they confirm.
Two things while they work. The password comes out of their password manager at twenty characters
or more, because upstream's shipped rule accepts six characters with one digit and this hostname
is public. And when the wizard offers to connect a database, choose to add data later.

Once they confirm, prove the door is shut:

```bash
curl -sS https://<DOMAIN>/api/session/properties | grep -oE '"(has-user-setup|setup-token)":[a-z]*'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"token":"CHANGE_ME","user":{"first_name":"a","last_name":"b","email":"a@example.com","password":"CHANGE_ME"},"prefs":{"site_name":"x"}}' https://<DOMAIN>/api/setup
```

Assert: the first prints `"has-user-setup":true` and `"setup-token":null`, the second `400`.
Upstream clears the token the moment the first user exists, so the value published a minute ago
is gone for good, and `/api/setup` refuses a token that does not match. Both before you report
success.

STOP: tell the user to sign in and do two things, then wait. Do not continue until they confirm.
One: open `+ New`, choose `Question`, pick the `Orders` table of the `Sample Database` that ships
inside the image, summarize it as a count of rows grouped by a date column, and save it. That is
the whole loop, on data already there. Two: click the grid icon top right, choose `Admin`, then
`Databases` and `Add a database`, and connect one of their own. The address they type has to be
reachable from the container, so `localhost` there is the container and not the VPS.

## 8. First backup and restore

Two artifacts. The database holds every account, question, dashboard and connection detail; the
config archive holds what rebuilds the service around it, including the key without which those
connection details are unreadable.

```bash
cd /srv/metabase
docker compose exec -T postgres pg_dump -U metabase -d metabase | gzip > /srv/metabase/backups/metabase-db-$(date +%F).sql.gz
sudo tar -czf /srv/metabase/backups/metabase-config-$(date +%F).tar.gz -C /srv/metabase compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/metabase/backups/
```

Assert: both exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. A backup on the same disk is not a backup,
so run this from the user's machine:

```bash
mkdir -p ~/backups/metabase
scp vps:/srv/metabase/backups/* ~/backups/metabase/
```

To restore: `docker compose down`, `sudo rm -rf /srv/metabase/postgres`, recreate it as in step
2, untar the config archive into /srv/metabase so `.env` is back before anything starts,
`docker compose up -d postgres`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U metabase -d metabase`, then `docker compose up -d`.
Order matters: a Metabase started against an empty database writes a fresh schema and you do this
twice. Say what is in neither file, because it is what people assume: none of the data they
analyse, which is read live from the database they connected and never copied here.

## 9. Updating later

New versions are listed at https://github.com/metabase/metabase/releases. Read the numbers.
One repository ships two lines: `v0.x` tags are the open source build published as the
`metabase/metabase` image, `v1.x` tags the commercial build published as
`metabase/metabase-enterprise`, which wants a license key. `v1.63.2` is this same release under a
different license, so stay on `v0`.

Docker Hub also carries patch tags upstream has not tagged in the repository, so a tag there is
not proof the source behind it is public. This pins 0.63.2 because it is the newest release
upstream has both tagged and published.

Take both backup artifacts first, then edit the image line in /srv/metabase/compose.yml to the
new tag and its digest:

```bash
cd /srv/metabase
docker compose pull
docker compose up -d
docker compose logs --tail 40 metabase
```

Metabase migrates on the way up, and upstream is explicit that a major version upgrade changes
the schema, with going back meaning either the backup or a `migrate down` run from the higher
version. Watch the log until it settles, then re-run step 7's health check.

## 10. What will probably go wrong

The first boot looks like a hang. I ran `docker compose up -d`, curled the hostname, got a `502`
from Caddy, curled again a minute later and got a `503` whose body said `{"status":"initializing"`,
and spent several minutes certain the reverse proxy was wrong. It was not. Metabase runs its whole
migration set against an empty PostgreSQL before it answers anything, and on a small box that is
two to four minutes in which every symptom of a broken install is present. Do not restart the
container to hurry it, and leave the Caddy block alone until step 7's loop has run forty times.

## 11. Out of scope

- Do not configure SMTP. Metabase runs without it. Dashboard subscriptions, alerts and the
  password reset link do not, and each wants a relay a fresh VPS on port 25 will not be.
- Do not switch to the `metabase-enterprise` image and do not set a license token. That build is
  under the Metabase Commercial License and needs a subscription.
- Do not put driver jars in /plugins. It is rebuilt from the image on every start, and the pinned
  image already carries PostgreSQL, MySQL, SQL Server, SQLite, MongoDB and BigQuery.
- Do not publish 3000 or 5432 on the host and do not open either in the firewall. Caddy is the
  only way in, and PostgreSQL is reachable only from the other container.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Metabase 0.63.2 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.

Read this before step 1, because it is the shape of the whole thing. Metabase keeps its own data
in a PostgreSQL you also run here: your accounts, your questions, your dashboards and the
encrypted connection details. The data you analyse is not in it. That lives in whatever
databases you connect afterwards, Metabase reads them live over the network, and step 7 is where
you connect the first one.

## 1. Preflight

Metabase runs on the JVM, which takes roughly a quarter of the memory it can see as its heap
ceiling. On a 2 GB box that is a 512 MB heap, and the bill arrives later as a container that
dies during somebody's third question. Upstream's own Azure guide asks for at least 3.5 GB.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `4096` MB available, at least `10` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a hostname that does
not resolve and failed attempts count against a rate limit you cannot see. If the memory number
is short, stop here rather than installing and hoping: this is the one number on this page that
is not a preference.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/metabase /srv/metabase/backups
sudo install -d -m 700 /srv/metabase/postgres
ls -la /srv/metabase
```

You should see: `backups` owned by you, and `postgres` at mode `700` owned by `root`.

If you do not: the two owners are deliberate. The PostgreSQL image chowns its own data directory
to its own uid on first start, so root is correct there and you should not fix it. Metabase gets
no directory at all: it drops to uid 2000 and writes only `/plugins` inside the container, where
it re-extracts the bundled Sample Database on every start. Nothing there needs to survive,
because every account, question, dashboard and connection detail is a row in PostgreSQL.

## 3. Secrets

Two secrets. `MB_DB_PASS` is the PostgreSQL password. `MB_ENCRYPTION_SECRET_KEY` is the key
Metabase encrypts stored connection details with, and the command below is upstream's own
instruction for making one. `MB_SITE_URL` shares the file and is not a secret: it is the address
Metabase builds its links from, and it takes no trailing slash.

```bash
umask 077
cat > /srv/metabase/.env <<EOF
MB_SITE_URL=https://<DOMAIN>
MB_DB_PASS=$(openssl rand -hex 32)
MB_ENCRYPTION_SECRET_KEY=$(openssl rand -base64 32)
EOF
chmod 600 /srv/metabase/.env
umask 022
ls -l /srv/metabase/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take, which happens when the
lines are pasted separately into different shells. Run `chmod 600 /srv/metabase/.env` and carry
on. If the file already existed from an earlier attempt, this has now replaced both values, which
is harmless before the first boot and expensive afterwards: a changed `MB_DB_PASS` no longer
matches the database PostgreSQL already built, and a changed encryption key makes every stored
connection detail unreadable.

Do not paste that file, either value, or any command output containing them into this chat
window. Read the key once with `sudo grep MB_ENCRYPTION_SECRET_KEY /srv/metabase/.env`, put it in
your password manager, and keep it somewhere other than where your backups land. Upstream is
blunt about this: without that key, connection details in a restored database cannot be
decrypted and every data source has to be entered again.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/metabase/compose.yml <<'EOF'
# Metabase · the deterministic fallback. Authored by caniselfhostit from the
# upstream docs and source at the pinned tag:
#   docker + app db .... https://github.com/metabase/metabase/blob/v0.63.2/docs/installation-and-operation/running-metabase-on-docker.md
#   variables .......... https://github.com/metabase/metabase/blob/v0.63.2/docs/configuring-metabase/environment-variables.md
#
# Two services: Metabase, and the PostgreSQL holding its own data, the
# accounts, questions, dashboards and encrypted connection details. What you
# analyse stays in the databases you connect later, read over the network.
#
# Digests read from Docker Hub on 2026-08-14; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.6-alpine@sha256:432b3b824c0769275ec9b0947736ef8b376d6997bcaa9de29818f613819c2feb
    container_name: metabase-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: metabase
      POSTGRES_USER: metabase
      POSTGRES_PASSWORD: ${MB_DB_PASS}
    volumes:
      # The 18 image puts the cluster one level down, so mount the parent.
      - /srv/metabase/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U metabase -d metabase"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  metabase:
    image: metabase/metabase:v0.63.2@sha256:252f8c9bd56dd21158005675b55876cf9fb838e0a0e0541581af859eafe1f32e
    container_name: metabase
    restart: unless-stopped
    # MB_SITE_URL, MB_DB_PASS and MB_ENCRYPTION_SECRET_KEY live here, mode 600.
    env_file: /srv/metabase/.env
    environment:
      # Without this the image writes an H2 file no mount here catches.
      MB_DB_TYPE: postgres
      MB_DB_HOST: postgres
      MB_DB_PORT: "5432"
      MB_DB_DBNAME: metabase
      MB_DB_USER: metabase
      # Default is true; an env var outranks what the wizard writes.
      MB_ANON_TRACKING_ENABLED: "false"
      # Every report and every scheduled hour is read in this zone.
      JAVA_TIMEZONE: UTC
    healthcheck:
      # Upstream's own: 503 with a progress number while migrations run.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://localhost:3000/api/health"]
      interval: 15s
      timeout: 10s
      retries: 20
      start_period: 120s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8210.
      - "127.0.0.1:8210:3000"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/metabase && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK`, and nothing else.

If you do not: `variable is not set` for `MB_DB_PASS` means step 3's file is missing or is not in
/srv/metabase, because compose reads `.env` from the directory you run it in. A YAML error is
almost always a partial paste; delete the file and paste the whole block again in one go.

## 5. Caddy and TLS

Copy the Caddyfile first. A syntax error here takes down every site on this box, not only this
one.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-metabase
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Metabase · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/metabase/metabase/blob/v0.63.2/src/metabase/server/middleware/security.clj
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also MB_SITE_URL in .env, which is
# where Metabase builds its links from. No security headers here: Metabase
# already sends HSTS, a CSP with frame-ancestors, X-Frame-Options DENY and
# nosniff of its own.

<DOMAIN> {
	encode zstd gzip

	header {
		-Server
	}

	# 8210 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8210
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and nothing at all from the reload.

If you do not: put the backup back with
`sudo cp /etc/caddy/Caddyfile.before-metabase /etc/caddy/Caddyfile`, reload, and read what
validate objected to. The usual cause is `<DOMAIN>` still being literal in the block you pasted.
Caddy gets the certificate on the first request and renews it itself, so there is nothing to
schedule.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, and rules for 80/tcp, 443/tcp and 443/udp.

If you do not: 80/tcp answers the ACME challenge, 443/tcp is the only way in, 443/udp is HTTP/3.
If you also see a rule for 8210 or 5432 from an earlier attempt, delete it with
`sudo ufw delete allow 8210`. Neither should ever be open: 8210 is bound to 127.0.0.1 and 5432
is never published to the host at all.

## 7. Start and verify

The first boot runs the whole migration set against an empty PostgreSQL and then extracts the
Sample Database, so it takes minutes rather than seconds. The loop is not decoration.

```bash
cd /srv/metabase
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health; echo
curl -sSL https://<DOMAIN>/ | grep -c '<title>Metabase</title>'
curl -sS https://<DOMAIN>/api/session/properties | grep -oE '"(has-user-setup":[a-z]*|setup-token":")'
```

You should see: the loop counting up and ending on `200`, then `{"status":"ok"}`, then a number
above `0`, then two lines, `"has-user-setup":false` and `"setup-token":"`.

If you do not: a `503` whose body reads `{"status":"initializing"` with a progress number is this
step unfinished rather than a failure, so let the loop run. A `502` from Caddy for all forty
iterations means the container is not answering at all: run `docker compose logs --tail 40
metabase` and `docker compose logs --tail 20 postgres`. A container that exits by itself on a
database error points back at step 3, because the password in .env and the one PostgreSQL was
built with have to match. A running container is not success.

Those last two lines are the open door, and you should read them as one sentence. Metabase mints
a setup token on first launch and publishes it as a public setting, so anyone who loads this
hostname right now can post it to `/api/setup` and become the administrator of your instance.
Nothing else is needed. Close it in the next five minutes rather than tomorrow.

Open https://<DOMAIN>/setup in a browser and complete the wizard, which creates your
administrator account. Two things while you are in there. Your password comes out of your
password manager at twenty characters or more, because upstream's shipped rule accepts six
characters with one digit and this hostname is on the open internet. And when the wizard offers
to connect a database, choose to add data later; you do that properly at the end of this step.

Then prove the door is shut:

```bash
curl -sS https://<DOMAIN>/api/session/properties | grep -oE '"(has-user-setup|setup-token)":[a-z]*'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"token":"CHANGE_ME","user":{"first_name":"a","last_name":"b","email":"a@example.com","password":"CHANGE_ME"},"prefs":{"site_name":"x"}}' https://<DOMAIN>/api/setup
```

You should see: `"has-user-setup":true` and `"setup-token":null` from the first command, and
`400` from the second.

If you do not: `"has-user-setup":false` means the wizard did not finish, so go back and finish
it, and do not walk away from the machine until it says true. Upstream clears the setup token the
moment the first user exists, which is why the value you saw a minute ago is now `null`, and
`/api/setup` answers `400` to a token that does not match. Both of these before you call this
done.

Now the part the install is for. Sign in and do two things. One: open `+ New`, choose `Question`,
pick the `Orders` table of the `Sample Database` that ships inside the image, summarize it as a
count of rows grouped by a date column, and save it. That is the entire loop, on data that is
already there, and it takes about a minute. Two: click the grid icon top right, choose `Admin`,
then `Databases` and `Add a database`, and connect one of your own. The address you type has to
be reachable from inside the container, so `localhost` there means the Metabase container and
not the VPS: a database running elsewhere on this same box is reached at the Docker bridge
address or by putting both on one compose network.

## 8. First backup and restore

Two artifacts. The database holds every account, question, dashboard and connection detail; the
config archive holds what rebuilds the service around it, including the key without which those
connection details are unreadable.

```bash
cd /srv/metabase
docker compose exec -T postgres pg_dump -U metabase -d metabase | gzip > /srv/metabase/backups/metabase-db-$(date +%F).sql.gz
sudo tar -czf /srv/metabase/backups/metabase-config-$(date +%F).tar.gz -C /srv/metabase compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/metabase/backups/
```

You should see: two files, both with a size that is not `0`.

If you do not: an empty `.sql.gz` means `pg_dump` failed, usually because the container is not up
yet. Nothing was stopped for this, because `pg_dump` snapshots a running database consistently.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/metabase
scp vps:/srv/metabase/backups/* ~/backups/metabase/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/metabase/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is one saved question:

```bash
cd /srv/metabase
docker compose down
sudo rm -rf /srv/metabase/postgres
sudo install -d -m 700 /srv/metabase/postgres
sudo tar -C /srv/metabase -xzf /srv/metabase/backups/metabase-config-$(date +%F).tar.gz compose.yml .env
docker compose up -d postgres
sleep 20
gunzip -c /srv/metabase/backups/metabase-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U metabase -d metabase
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/session/properties | grep -oE '"has-user-setup":[a-z]*'
```

You should see: the loop ending on `200`, then `"has-user-setup":true`, from a database volume
that was deleted two minutes ago, which means your account and your saved question came back.

If you do not: `"has-user-setup":false` means Metabase started against an empty database and
wrote a fresh schema into it, which is what happens if you start the app before loading the dump.
That is why `docker compose up -d postgres` names the one service. Run the block again in order.
Neither archive contains any of the data you analyse: that is read live from the database you
connected and is its own owner's backup problem.

## 9. Updating later

New versions are listed at https://github.com/metabase/metabase/releases. Read the numbers. One
repository ships two lines: `v0.x` tags are the open source build published as the
`metabase/metabase` image, `v1.x` tags the commercial build published as
`metabase/metabase-enterprise`, which wants a license key. `v1.63.2` is this same release under a
different license, so stay on `v0`.

Docker Hub also carries patch tags upstream has not tagged in the repository, so a tag there is
not proof the source behind it is public. This install pins 0.63.2 because it is the newest
release upstream has both tagged and published.

Take both backup artifacts first, then edit the `image:` line in /srv/metabase/compose.yml to the
new tag and its digest.

```bash
cd /srv/metabase
docker compose pull
docker compose up -d
docker compose logs --tail 40 metabase
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Upstream is
explicit that a major version upgrade changes the application database schema, and that going
back afterwards means either restoring the backup or running `migrate down` from the higher
version, so the archive you took two minutes ago is the rollback plan rather than a formality.

## 10. What will probably go wrong

The first boot looks like a hang. I ran `docker compose up -d`, curled the hostname, got a `502`
from Caddy, curled again a minute later and got a `503` whose body said `{"status":"initializing"`,
and spent several minutes certain the reverse proxy was wrong. It was not. Metabase runs its whole
migration set against an empty PostgreSQL before it answers anything, and on a small box that is
two to four minutes in which every symptom of a broken install is present. Do not restart the
container to hurry it, and leave the Caddy block alone until step 7's loop has run forty times.

## 11. Out of scope

- Do not configure SMTP. Metabase runs without it. Dashboard subscriptions, alerts and the
  password reset link do not, and each wants a relay a fresh VPS on port 25 will not be.
- Do not switch to the `metabase-enterprise` image and do not set a license token. That build is
  under the Metabase Commercial License and needs a subscription.
- Do not put driver jars in /plugins. It is rebuilt from the image on every start, and the pinned
  image already carries PostgreSQL, MySQL, SQL Server, SQLite, MongoDB and BigQuery.
- Do not publish 3000 or 5432 on the host and do not open either in the firewall. Caddy is the
  only way in, and PostgreSQL is reachable only from the other container.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Metabase 0.63.2 under ~/selfhost/metabase, answering at http://localhost:8210.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. Metabase is the screen a team reads together, and this one answers at http://localhost:8210:
no colleague, no phone, no other laptop on the wifi, and scheduled work stops whenever the
machine sleeps. What they get is a private analysis tool over databases they can reach.

Detect the OS and measure:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. This install needs 4096 MB of RAM available
and 10 GB free on the home disk; both images publish amd64 and arm64. Metabase runs on the JVM,
which takes about a quarter of the memory it can see as its heap ceiling, and here that is
whatever Docker Desktop was given, so raise its Resources limit rather than calling the machine
too small. If either number is short, print both and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/metabase/backups
ls -la ~/selfhost/metabase
```

Assert: `ls -la` shows `backups`. There is no data directory: the application database is a named
volume (the compose header says why) and Metabase writes only `/plugins` in its container. No
ownership fix is needed on any OS.

## 4. Secrets

Two secrets: `MB_DB_PASS` for PostgreSQL, and `MB_ENCRYPTION_SECRET_KEY`, the key Metabase
encrypts stored connection details with, from upstream's own command. Never print either.

```bash
umask 077
cat > ~/selfhost/metabase/.env <<EOF
MB_SITE_URL=http://localhost:8210
MB_DB_PASS=$(openssl rand -hex 32)
MB_ENCRYPTION_SECRET_KEY=$(openssl rand -base64 32)
EOF
chmod 600 ~/selfhost/metabase/.env
umask 022
ls -l ~/selfhost/metabase/.env
```

Assert: mode `-rw-------`. On Windows those bits are advisory and the real boundary is the user's
own account, which on a single-user machine is the boundary that matters. Tell the user the key
is in ~/selfhost/metabase/.env, that they read it with
`grep MB_ENCRYPTION_SECRET_KEY ~/selfhost/metabase/.env`, and that connection details in a
restored database cannot be decrypted without it. It belongs in a password manager, not next to
the backups.

## 5. compose.yml

```bash
cat > ~/selfhost/metabase/compose.yml <<'EOF'
# Metabase · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream docs and source at the pinned tag:
#   docker + app db .... https://github.com/metabase/metabase/blob/v0.63.2/docs/installation-and-operation/running-metabase-on-docker.md
#   variables .......... https://github.com/metabase/metabase/blob/v0.63.2/docs/configuring-metabase/environment-variables.md
#
# Two services on the computer you are sitting at, paths relative to
# ~/selfhost/metabase/ so one file works on macOS, Linux and Windows. The
# PostgreSQL data directory is a named volume, not a bind mount, because that
# image chowns it to its own uid, which a Windows home-folder bind mount
# cannot allow. It holds the accounts, questions, dashboards and encrypted
# connection details; what you analyse stays in the databases you connect.
#
# Digests read from Docker Hub on 2026-08-14; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.6-alpine@sha256:432b3b824c0769275ec9b0947736ef8b376d6997bcaa9de29818f613819c2feb
    container_name: metabase-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: metabase
      POSTGRES_USER: metabase
      POSTGRES_PASSWORD: ${MB_DB_PASS}
    volumes:
      - metabase-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U metabase -d metabase"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  metabase:
    image: metabase/metabase:v0.63.2@sha256:252f8c9bd56dd21158005675b55876cf9fb838e0a0e0541581af859eafe1f32e
    container_name: metabase
    restart: unless-stopped
    # MB_SITE_URL, MB_DB_PASS and MB_ENCRYPTION_SECRET_KEY live here, mode 600.
    env_file: ./.env
    environment:
      # Without this the image writes an H2 file no mount here catches.
      MB_DB_TYPE: postgres
      MB_DB_HOST: postgres
      MB_DB_PORT: "5432"
      MB_DB_DBNAME: metabase
      MB_DB_USER: metabase
      # Default is true; an env var outranks what the wizard writes.
      MB_ANON_TRACKING_ENABLED: "false"
      # Every report and every scheduled hour is read in this zone.
      JAVA_TIMEZONE: UTC
    healthcheck:
      # Upstream's own: 503 with a progress number while migrations run.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://localhost:3000/api/health"]
      interval: 15s
      timeout: 10s
      retries: 20
      start_period: 120s
    ports:
      # Loopback only: no other device on the wifi can reach 8210.
      - "127.0.0.1:8210:3000"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  metabase-pgdata:
EOF
cd ~/selfhost/metabase && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. A certificate attests
a public name nothing here has, and browsers treat http://localhost as a secure context anyway.
8210 is bound to 127.0.0.1, this computer only:

```bash
grep -c '"127.0.0.1:' ~/selfhost/metabase/compose.yml
```

Assert: that prints `1`. One published port, on loopback, and PostgreSQL publishes nothing. A
loopback binding governs what arrives, not what Metabase can call outward.

## 7. Start and verify

The first boot runs the whole migration set, so it takes minutes.

```bash
cd ~/selfhost/metabase
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8210/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8210/api/health; echo
curl -sS http://localhost:8210/api/session/properties | grep -oE '"(has-user-setup":[a-z]*|setup-token":")'
```

Assert all three and print what you received. The loop ends printing `200`. The health call
prints `{"status":"ok"}`, which upstream returns only once start-up is complete and the
application database answers. The third prints `"has-user-setup":false` and `"setup-token":"`:
Metabase mints a setup token on first launch and publishes it as a public setting, readable here
only from this computer.

If any of the three misses, stop, run `docker compose logs --tail 40 metabase` and
`docker compose logs --tail 20 postgres`. A `503` reading `{"status":"initializing"` is this step
unfinished, not a failure. If `port is already allocated` came back, find what holds 8210 with
`lsof -nP -iTCP:8210 -sTCP:LISTEN`, or `netstat -ano | findstr :8210` on Windows.

STOP: tell the user to open http://localhost:8210/setup, complete the wizard that creates their
administrator account, and wait. Do not continue until they confirm.
The password still belongs in a password manager: upstream's shipped rule accepts six characters
with one digit, and this database will hold credentials for every data source they connect. When
the wizard offers a database, choose to add data later.

Then:

```bash
curl -sS http://localhost:8210/api/session/properties | grep -oE '"(has-user-setup|setup-token)":[a-z]*'
```

Assert: `"has-user-setup":true` and `"setup-token":null`. Upstream clears the token the moment
the first user exists, so it can never be used again.

STOP: tell the user to sign in and do two things, then wait. Do not continue until they confirm.
One: open `+ New`, choose `Question`, pick the `Orders` table of the `Sample Database` that ships
inside the image, summarize it as a count of rows grouped by a date column, and save it. That is
the whole loop, on data already there. Two: under the grid icon, `Admin`, `Databases`,
`Add a database`, connect one of their own. `localhost` there is the Metabase container, not this
machine; a database on this machine is `host.docker.internal`.

## 8. First backup and restore

Two artifacts. The database holds every account, question, dashboard and connection detail; the
config archive holds what rebuilds the service around it, including the key. Being a named
volume, it is dumped, not copied.

```bash
cd ~/selfhost/metabase
docker compose exec -T postgres pg_dump -U metabase -d metabase | gzip > ~/selfhost/metabase/backups/metabase-db-$(date +%F).sql.gz
tar -C ~/selfhost/metabase -czf ~/selfhost/metabase/backups/metabase-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/metabase/backups/
```

Assert: both exist and are non-empty. Print both sizes. Nothing is stopped, because `pg_dump`
snapshots a running database consistently.

Both archives sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a sync folder or a USB stick,
and copy both there with `cp`; in Git Bash that is `/d/Backups`, not `D:\Backups`. Assert: the
user confirms both names are there. If they have nowhere, say so plainly.

To restore: `docker compose down -v`, `tar -xzf backups/metabase-config-<date>.tar.gz`,
`docker compose up -d postgres`, wait twenty seconds, then
`gunzip -c backups/metabase-db-<date>.sql.gz | docker compose exec -T postgres psql -U metabase -d metabase`,
and `docker compose up -d`. Order matters: a Metabase started against an empty database writes a
fresh schema and you do this twice. Neither archive holds the data they analyse.

## 9. Updating later

New versions are listed at https://github.com/metabase/metabase/releases. One repository ships
two lines: `v0.x` is the open source build published as `metabase/metabase`, `v1.x` the
commercial build published as `metabase/metabase-enterprise`, so stay on `v0`. Docker Hub also
carries patch tags upstream has not tagged in the repository, so a tag there is no proof the
source behind it is public; this pins 0.63.2, the newest release upstream has tagged.

Back up first, then edit the image line in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/metabase
docker compose pull
docker compose up -d
docker compose logs --tail 40 metabase
```

A major version upgrade changes the schema, and upstream is explicit that going back means the
backup or a `migrate down` from the higher version. Watch the log until it settles, then re-run
step 7's health check.

## 10. What will probably go wrong

I closed the laptop, opened it next morning, went to http://localhost:8210 and got nothing at
all. Docker Desktop had not come back after the reboot; the second time it had, but Metabase was
four minutes into its migrations and answering `503` with `{"status":"initializing"` in the body,
which looks exactly like a broken install if you do not read it. Turn on
Docker Desktop's start-at-login, and after a reboot run
`cd ~/selfhost/metabase && docker compose up -d` and wait out step 7's loop.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8210 to 0.0.0.0 so a phone on the wifi can load a dashboard. That puts a tool
  holding credentials for every connected database on every network this machine joins.
- Do not switch to the `metabase-enterprise` image, set a license token, or configure SMTP. The
  first two need a subscription, the third a mail relay on a laptop.
````

## docker-compose.yml

```yaml
# Metabase · the deterministic fallback. Authored by caniselfhostit from the
# upstream docs and source at the pinned tag:
#   docker + app db .... https://github.com/metabase/metabase/blob/v0.63.2/docs/installation-and-operation/running-metabase-on-docker.md
#   variables .......... https://github.com/metabase/metabase/blob/v0.63.2/docs/configuring-metabase/environment-variables.md
#
# Two services: Metabase, and the PostgreSQL holding its own data, the
# accounts, questions, dashboards and encrypted connection details. What you
# analyse stays in the databases you connect later, read over the network.
#
# Digests read from Docker Hub on 2026-08-14; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.6-alpine@sha256:432b3b824c0769275ec9b0947736ef8b376d6997bcaa9de29818f613819c2feb
    container_name: metabase-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: metabase
      POSTGRES_USER: metabase
      POSTGRES_PASSWORD: ${MB_DB_PASS}
    volumes:
      # The 18 image puts the cluster one level down, so mount the parent.
      - /srv/metabase/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U metabase -d metabase"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  metabase:
    image: metabase/metabase:v0.63.2@sha256:252f8c9bd56dd21158005675b55876cf9fb838e0a0e0541581af859eafe1f32e
    container_name: metabase
    restart: unless-stopped
    # MB_SITE_URL, MB_DB_PASS and MB_ENCRYPTION_SECRET_KEY live here, mode 600.
    env_file: /srv/metabase/.env
    environment:
      # Without this the image writes an H2 file no mount here catches.
      MB_DB_TYPE: postgres
      MB_DB_HOST: postgres
      MB_DB_PORT: "5432"
      MB_DB_DBNAME: metabase
      MB_DB_USER: metabase
      # Default is true; an env var outranks what the wizard writes.
      MB_ANON_TRACKING_ENABLED: "false"
      # Every report and every scheduled hour is read in this zone.
      JAVA_TIMEZONE: UTC
    healthcheck:
      # Upstream's own: 503 with a progress number while migrations run.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://localhost:3000/api/health"]
      interval: 15s
      timeout: 10s
      retries: 20
      start_period: 120s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8210.
      - "127.0.0.1:8210:3000"
    depends_on:
      postgres:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Metabase · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream docs and source at the pinned tag:
#   docker + app db .... https://github.com/metabase/metabase/blob/v0.63.2/docs/installation-and-operation/running-metabase-on-docker.md
#   variables .......... https://github.com/metabase/metabase/blob/v0.63.2/docs/configuring-metabase/environment-variables.md
#
# Two services on the computer you are sitting at, paths relative to
# ~/selfhost/metabase/ so one file works on macOS, Linux and Windows. The
# PostgreSQL data directory is a named volume, not a bind mount, because that
# image chowns it to its own uid, which a Windows home-folder bind mount
# cannot allow. It holds the accounts, questions, dashboards and encrypted
# connection details; what you analyse stays in the databases you connect.
#
# Digests read from Docker Hub on 2026-08-14; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.6-alpine@sha256:432b3b824c0769275ec9b0947736ef8b376d6997bcaa9de29818f613819c2feb
    container_name: metabase-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: metabase
      POSTGRES_USER: metabase
      POSTGRES_PASSWORD: ${MB_DB_PASS}
    volumes:
      - metabase-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U metabase -d metabase"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  metabase:
    image: metabase/metabase:v0.63.2@sha256:252f8c9bd56dd21158005675b55876cf9fb838e0a0e0541581af859eafe1f32e
    container_name: metabase
    restart: unless-stopped
    # MB_SITE_URL, MB_DB_PASS and MB_ENCRYPTION_SECRET_KEY live here, mode 600.
    env_file: ./.env
    environment:
      # Without this the image writes an H2 file no mount here catches.
      MB_DB_TYPE: postgres
      MB_DB_HOST: postgres
      MB_DB_PORT: "5432"
      MB_DB_DBNAME: metabase
      MB_DB_USER: metabase
      # Default is true; an env var outranks what the wizard writes.
      MB_ANON_TRACKING_ENABLED: "false"
      # Every report and every scheduled hour is read in this zone.
      JAVA_TIMEZONE: UTC
    healthcheck:
      # Upstream's own: 503 with a progress number while migrations run.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://localhost:3000/api/health"]
      interval: 15s
      timeout: 10s
      retries: 20
      start_period: 120s
    ports:
      # Loopback only: no other device on the wifi can reach 8210.
      - "127.0.0.1:8210:3000"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  metabase-pgdata:
```

## Caddyfile

```text
# Metabase · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/metabase/metabase/blob/v0.63.2/src/metabase/server/middleware/security.clj
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. That hostname is also MB_SITE_URL in .env, which is
# where Metabase builds its links from. No security headers here: Metabase
# already sends HSTS, a CSP with frame-ancestors, X-Frame-Options DENY and
# nosniff of its own.

<DOMAIN> {
	encode zstd gzip

	header {
		-Server
	}

	# 8210 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8210
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Metabase · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=metabase.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation and source at the
# pinned tag:
#   https://github.com/metabase/metabase/blob/v0.63.2/docs/installation-and-operation/running-metabase-on-docker.md
#   https://github.com/metabase/metabase/blob/v0.63.2/docs/configuring-metabase/environment-variables.md
#   https://github.com/metabase/metabase/blob/v0.63.2/bin/docker/run_metabase.sh
#
# Two secrets are generated here, on this machine: MB_DB_PASS for PostgreSQL,
# and MB_ENCRYPTION_SECRET_KEY, the key Metabase encrypts stored database
# connection details with. Both land in /srv/metabase/.env at mode 600 and
# neither is printed. Losing the encryption key means the connection details in
# a restored application database cannot be decrypted.
#
# This script cannot create the administrator account, because only a browser
# can. It stops with the setup wizard still open and tells you to go and claim
# it. Until you do, the setup token Metabase publishes as a public setting is
# readable by whoever loads the hostname first, and posting it to /api/setup
# makes them the administrator.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/metabase}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. metabase.example.com"
case "$DOMAIN_HOST" in
	*/*) die "DOMAIN_HOST is a hostname, not a URL: no scheme and no trailing slash" ;;
esac
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

# Metabase runs on the JVM, which takes about a quarter of the memory it can
# see as its heap ceiling. Upstream's own Azure guide asks for at least 3.5 GB.
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; Metabase plus PostgreSQL wants 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# Two owners. The PostgreSQL image chowns its own data directory on first
# start, so that one stays with root. Metabase gets no directory at all: its
# entrypoint drops to uid 2000 and the only path it writes is /plugins inside
# the container, where it re-extracts the bundled Sample Database every start.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the two secrets, on the server ------------------------------
#
# Read the encryption key later with
#   sudo grep MB_ENCRYPTION_SECRET_KEY /srv/metabase/.env
# and keep it somewhere other than where the backups land.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		MB_SITE_URL=https://${DOMAIN_HOST}
		MB_DB_PASS=$(openssl rand -hex 32)
		MB_ENCRYPTION_SECRET_KEY=$(openssl rand -base64 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-metabase"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8210 nor 5432 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8210 and 5432 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first boot runs the whole migration set against an empty PostgreSQL and
# then extracts the Sample Database, so it takes minutes. /api/health answers
# 503 with a progress number until that finishes, which is not a failure.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/health"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/health" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/api/health answered ${code:-nothing}. Check: docker compose logs --tail 40 metabase"

curl -sS "https://${DOMAIN_HOST}/api/health" | grep -q '"status":"ok"' \
	|| die "/api/health did not report status ok. Check: docker compose logs --tail 20 postgres"

# The title Metabase renders into its own page. Its absence means Caddy is
# reaching something other than Metabase.
curl -sSL "https://${DOMAIN_HOST}/" | grep -q '<title>Metabase</title>' \
	|| die "https://${DOMAIN_HOST}/ is not serving Metabase's own page"

# With no user in the database, has-user-setup is false and the setup token is
# published to anyone who asks. Confirming it is there is the last assert here;
# closing it is the first thing you do next.
props="$(curl -sS "https://${DOMAIN_HOST}/api/session/properties")"
printf '%s' "$props" | grep -q '"has-user-setup":false' \
	|| die "has-user-setup is already true; a user exists on this instance and it is not yours"
printf '%s' "$props" | grep -q '"setup-token":"' \
	|| die "no setup token is published, so the wizard cannot be completed. Check the container logs."
unset props

# --- 7. The first backup, before day one ends --------------------------------
#
# Two artifacts. pg_dump snapshots a running database consistently, so nothing
# is stopped. The config archive carries the live Caddy site block and the .env
# holding the key that decrypts stored connection details.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U metabase -d metabase | gzip > "$APP_DIR/backups/metabase-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/metabase-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/metabase-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/metabase-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

cat <<-DONE

	Metabase is answering at https://${DOMAIN_HOST}

	  1. Do this now, before anything else: open
	       https://${DOMAIN_HOST}/setup
	     and complete the wizard to create the administrator account. Until you
	     do, Metabase publishes a setup token as a public setting, and whoever
	     reads it can post it to /api/setup and become the administrator of
	     this instance. Use a password from your password manager, twenty
	     characters or more: upstream's shipped rule accepts six characters
	     with one digit.
	  2. Then prove the door is shut. Both of these must be true:
	       curl -sS https://${DOMAIN_HOST}/api/session/properties | grep -oE '"(has-user-setup|setup-token)":[a-z]*'
	     should print "has-user-setup":true and "setup-token":null.
	  3. Your MB_ENCRYPTION_SECRET_KEY is in $APP_DIR/.env, mode 600. Read it
	     with
	       sudo grep MB_ENCRYPTION_SECRET_KEY $APP_DIR/.env
	     and put it in your password manager, somewhere other than where these
	     backups land. It was not printed here. Without it, the connection
	     details in a restored database cannot be decrypted.
	  4. Two databases now exist and they do different jobs. The PostgreSQL
	     this script started holds your accounts, questions and dashboards.
	     The data you analyse lives in whatever you connect under Admin >
	     Databases, is read live over the network, and is never copied here or
	     into these backups.
	  5. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy both files somewhere else
	     tonight.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/equals/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
