Can I self-host Equals?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Metabase. It takes one prompt, a 4096 MB VPS, and about 90 minutes. That is $2,000 a month you stop paying Equals — $24,000 a year on the Essential plan.
Why people pay for Equals
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Equals sells the spreadsheet a finance team already lives in, wired straight to the systems the numbers come from, plus a person who builds the first reports for you. The pitch is not the charts, it is that nobody has to export a CSV from Stripe on the first of the month again: connectors to accounting, billing, CRM, support and warehouse tools keep the sheet current on a schedule, formulas sit on top of live data instead of a stale paste, and a forward deployed analyst configures the syncs and hands back working dashboards inside a week. The bill is an annual contract with unlimited seats rather than a per-editor charge, which is honest about who the buyer is: a company that wants one set of numbers everybody trusts, and would rather pay for that than own it.
| Plan | List price | What it buys |
|---|---|---|
| Essentialthe plan this page prices against | $2,000/mo | The page prices this as $24k per year; the figure here is that divided by twelve. Unlimited viewers and unlimited editors, your choice of 3 connectors, a six hour data sync, and $250 a month of included AI spend with usage billing above it. Two implementations by a forward deployed analyst are included. No writeback, no unlimited row sizes, no SSO. There is no self-serve signup: every plan on this page is a demo request. |
| Business | $3,000/mo | Printed as $36k per year. Unlimited seats, 5 connectors or a warehouse connection, hourly sync, $500 a month of included AI spend, writeback to your CRM, and no row limits. Extra connectors are priced separately on the same page: $199 a month each for databases and scripts, $699 a month each for everything else. |
| Enterprise | $5,000/mo | Printed as $60k per year. Unlimited connectors or warehouse, a fifteen minute sync, $1,000 a month of included AI spend, writeback, no row limits, and SSO through Okta or Azure AD. Additional implementation work beyond the two included is $5k per ten hours. |
Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: high
Replaced by Metabase
One project, named before the prompt, so you know what you are about to install.
Dashboards, saved questions and a SQL editor pointed at the databases you already run, from two containers and no per-seat bill.
The half of Equals that is dashboards, saved questions and SQL over your live data, running on your own box for the price of a VPS with no seat count and no annual contract. Point it at the same Postgres, MySQL, BigQuery, Snowflake or Redshift the connectors were feeding and the reports come back. What it does not do is the spreadsheet: there is no grid of cells, no formulas over the result set, and nobody assigned to build your first five reports. Two containers, one setup wizard you must close within minutes of first boot, and a JVM that wants 4 GB. Honest about the boundary too, because the boundary is the point: Metabase reads the databases, it does not replace the ETL that fills them.
The swap
You'd run
Metabase
ONE EVENING · ~90 min to running · 4096 MB RAM
Equals Essential · vendor list price · checked 2026-08-14 · source
Before you start
- RAM floor
- 4096 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same Metabase: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
325 lines · 14,983 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
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.322 lines · 14,998 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.compose.local.ymlthe services, pinned · local layout66 lines
# 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:agent-readable mirror: /self-host/equals.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned61 lines
# 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_healthyCaddyfilethe hostname and TLS23 lines
# 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.shthe same install, no agent186 lines
#!/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.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Equals.
- One repository, two licenses, and the image here is the open one. Everything outside the top-level enterprise directory is AGPL-3.0; everything inside it is under the Metabase Commercial License and needs a subscription, which is where SSO, granular permissions, official support and the AI assistant live. The v0.x tags build the AGPL image this install pins, the v1.x tags build the commercial one, and they are the same release under different terms. GitHub reports the repository as NOASSERTION for exactly this reason.
- The spreadsheet does not come along. Equals sells a spreadsheet wired to live data, formulas and all, plus a human analyst who builds your first reports; Metabase gives you dashboards, saved questions and a SQL editor over the same databases, and the cell-by-cell surface is not part of it. If a spreadsheet is the actual requirement, Grist and Baserow are this catalog's answers to that shape. Grafana is the neighbouring answer for metrics and machines rather than revenue and customers.
- This is the window, not the warehouse. The PostgreSQL you run here holds accounts, questions, dashboards and encrypted connection details, and nothing else. The data you analyse stays in whatever databases you connect, is queried live on every dashboard load, and is neither copied into this install nor covered by its backup. If those databases are somebody else's managed service, this changes nothing about who backs them up.
- It is a JVM application and the memory floor is real. The JVM takes about a quarter of the memory it can see as its heap ceiling, so a 2 GB box gives Metabase a 512 MB heap and the failure shows up weeks later as a container that dies mid-query. Upstream's own cloud guide asks for at least 3.5 GB; this install asks for 4 GB and a first boot of several minutes while it migrates an empty database.
- No hosted refresh schedule, no managed connectors, no analyst on the account, no SSO, and no support contract. The connectors Equals bills for are the parts you replace with a hostname and a password; the analyst who builds your first five reports is the part you do not.
Where this came from
“Binaries located at hub.docker.com/metabase/metabase-enterprise and downloads.metabase.com/enterprise are released under the Metabase Commercial License. Binaries located at hub.docker.com/metabase/metabase and all non-enterprise paths at downloads.metabase.com are released under the AGPL.”
- GitHub reports the license as NOASSERTION because the repository carries two: LICENSE.txt states that source outside the top-level enterprise directory is AGPL, and source inside it is under the Metabase Commercial License unless otherwise noted. source
- Upstream recommends PostgreSQL for the application database and calls the embedded H2 default the local-demo option to avoid in production, which is why this install sets MB_DB_TYPE and runs a second container. source
- GET /api/health answers 200 with {"status":"ok"} only once start-up is complete and the application database is reachable, and 503 with a status of initializing and a progress number while migrations are still running. source
- The setup token is created on first launch and declared a public setting, so it is served to unauthenticated callers at /api/session/properties, and it is cleared the moment the first user is created. source
- POST /api/setup rejects a request whose token does not match, and refuses outright once a user exists, with the message that the route can only be used to create the first user. source
- MB_ENCRYPTION_SECRET_KEY encrypts stored database connection details, and upstream states plainly that connection details cannot be decrypted without it and would have to be reset in the admin panel. source
- Metabase uses a single SQL database for all of its runtime application data, so backing that database up is the whole backup, and a bundled Sample Database is extracted to the plugins directory on every launch. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Equals?
Not Equals itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Metabase. Dashboards, saved questions and a SQL editor pointed at the databases you already run, from two containers and no per-seat bill. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Equals?
Metabase. Dashboards, saved questions and a SQL editor pointed at the databases you already run, from two containers and no per-seat bill. The half of Equals that is dashboards, saved questions and SQL over your live data, running on your own box for the price of a VPS with no seat count and no annual contract. Point it at the same Postgres, MySQL, BigQuery, Snowflake or Redshift the connectors were feeding and the reports come back. What it does not do is the spreadsheet: there is no grid of cells, no formulas over the result set, and nobody assigned to build your first five reports. Two containers, one setup wizard you must close within minutes of first boot, and a JVM that wants 4 GB. Honest about the boundary too, because the boundary is the point: Metabase reads the databases, it does not replace the ETL that fills them. Metabase is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Equals?
4096 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Metabase itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Equals Essential, $2,000/mo — $24,000 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Metabase install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Metabase on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Metabase on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Metabase answers only at http://localhost:8210, so a dashboard nobody else can open is a dashboard you are reading alone, and every scheduled database sync stops while the computer sleeps. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.