Skip to content

fix(deploy): make the self-host install work on a clean server - #154

Merged
argiad merged 7 commits into
developmentfrom
fix/pg18-data-path
Nov 8, 2026
Merged

argiad merged 7 commits into
developmentfrom
fix/pg18-data-path

Conversation

@argiad

@argiad argiad commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Why

This is the first real run of the public one-liner on a clean server: Hetzner cx23 (2 vCPU, 4 GB), Ubuntu 24.04, --no-domain --non-interactive. Every fresh install failed, one step after another. Each fix below was found on that server, covered by a test that failed first, and then checked on a brand-new server again.

The finished install now completes in about 4 minutes with self-smoke passed. It uses about 1.8 GB of 3.8 GB RAM and 8.9 GB of disk, and runs 14 containers.

What was broken (in the order the server hit it)

# Symptom on a clean server Cause Fix
1 Both Postgres containers restart-loop; install dies at stack_up versions.env pins postgres:18-alpine. Postgres 18 refuses a volume at /var/lib/postgresql/data (docker-library/postgres#1259) PGDATA: /var/lib/postgresql/data on both services. The mount is not moved: moving it would make existing volumes look empty and initdb a blank cluster beside them
2 mm-core: password authentication failed for user "mm_admin" The rendered mm-postgres-init.sql is root:root 0600, and Postgres runs init scripts after dropping to the postgres user: init.sql: Permission denied. The cluster still counts as initialized, so a re-run never recovers Mount it at /run/mm-init/ and copy it in (owned by postgres) while the entrypoint is still root. The host file stays 0600
3 lk-jwt-service restart-loops Pinned to latest-ci (upstream's rolling main), which now requires LIVEKIT_FULL_ACCESS_HOMESERVERS Pin release 0.9.0 (upstream tags releases now) and set the variable to ${MM_DOMAIN}
4 mm-core exits: MM_STRIPE_SECRET_KEY required when monetization enabled Compose defaults payments on, and a plain install has no Stripe keys. The Console where you add the keys is served by that same mm-core, so there was no way out install.sh seeds MM_MONETIZATION_ENABLED from the keys (payments_seed) and says where to turn payments on. --demo generates throwaway sk_test_demo_/whsec_demo_ values for mm-fakestripe
5 Owner login gets 504 after 30 s; install stops silently Traefik had --providers.docker.network=mm_network, but the real name is matrixmedia_mm_network. Traefik then picks a network per container at random, so routes to Synapse, mm-core, mm-web, mm-switch and LiveKit could land on internal. Under pipefail, capture_admin_token also died before its die Use the prefixed name. Changing only that flag on the server took the Synapse routes from 504 to 200. Token capture now reports what the login answered
6 Self-smoke fails on a healthy stack It probed mm-core right after recreating it, before Traefik routes it. https://<domain>/.well-known/matrix/client was never routed on the bare domain (clients use it to find the homeserver) wait_healthy plus new wait_http before the smoke. The apex rule now forwards both well-known files to mm-web
7 mmctl doctor / check fail on a healthy install; every upgrade would "fail" its smoke and print the restore command All three passed MM_SYNAPSE_ADMIN_TOKEN to mm-core's admin health check (always 401) MM_ADMIN_TOKEN everywhere, matching install.sh. One test covers every caller
8 mmctl check: check_min: command not found mmctl didn't load lib/preflight.sh Load it
9 mmctl version prints dev on every piped install No .git in a downloaded tree The bootstrap records the ref in src/.deploy-ref
10 The installer's closing line sends the owner to a 403 https://matrix.<domain>/ is an optional landing mount; only the dashboard and viewer are baked into mm-web The closing line points at the Operator Console (/_mm/dashboard/) and the homeserver address. admin.credentials gains console=
11 --demo: lnbits unhealthy, install fails SUPER_USER: admin (lnbits needs a 128-bit hex id). This is the same line that broke the dev server in August Removed. The first web-UI account becomes the superuser, as the comment beside it already said. The fallback image now matches the pin

mmctl is also linked before the stack starts. Every failure message says "run: mmctl …", and until now the command didn't exist yet at that point.

Verification

  • deploy/test/fresh_install.bats (new) and additions to bootstrap, smoke, mmctl and pipe_bootstrap. Each test was watched failing before its fix. Mid-test [[ ]] checks use || false, because macOS /bin/bash 3.2 doesn't fail a test on them otherwise.
  • Full suite: 274 ok, 0 failed (2 environment skips: needs root, needs a Docker daemon). shellcheck is clean with the CI flags.
  • On a fresh server, one-liner pinned to this branch: exit 0, self-smoke passed.
    • mmctl doctor: passed. mmctl check: OK.
    • mmctl backup writes config plus both database dumps. mmctl upgrade backs up, rolls, and passes the smoke.
    • mmctl uninstall --purge removes every volume.
    • From outside: both well-known files, /versions, /login, federation version, the dashboard and viewer SPAs, and Element Call all answer 200. mm-switch metrics and health stay hidden.
  • --demo, after uninstall --purge on the same server: exit 0, self-smoke passed, lnbits healthy, mm-fakestripe up, payments on with generated test keys, 16 containers.
  • Not verified: signing in to the Operator Console with the generated owner account (that's a credential entry, so it was left for a human). The --domain path with real Let's Encrypt certificates, and federation, were not tested either.

Not changed here (owner decisions / follow-ups)

  • What https://matrix.<domain>/ should show on a self-host (it's a 403 until a landing page is mounted; the nginx root also aliases a dev-server steegler-landing path).
  • livekit.yaml has room.auto_create: true. Upstream lk-jwt-service says it must be false to actually restrict room creation, but mm-core may rely on it.
  • mm-postgres sets log_statement = 'ddl', so CREATE ROLE … PASSWORD '<plaintext>' lands in docker logs on the first start.
  • Backups are written 0644 (inside the 0700 /opt/mm).
  • The nightly deploy-e2e harness can't pass as written: it uses the retired cx22 type and a matrixmedia.app subdomain that doesn't resolve, and it never tests the one-liner.
  • Existing installs on Postgres ≤17 that pick up the postgres:18 pin need a pg_upgrade. With PGDATA pinned they fail loudly instead of silently.

Stage deploys use the host's own /opt/mm/docker-compose.yml and don't re-render these templates, so the dev server is unaffected.

🤖 Generated with Claude Code

argiad and others added 7 commits November 7, 2026 23:39
The first install on a clean server (Hetzner cx23, Ubuntu 24.04, the public
one-liner) failed three ways, each fatal on every fresh host:

1. versions.env pins postgres:18-alpine, but both database volumes are
   mounted at /var/lib/postgresql/data. From 18 on, the image keeps its
   cluster in /var/lib/postgresql/18/docker and refuses to start while a
   volume sits at the old path (docker-library/postgres#1259), so both
   databases restart-looped. Set PGDATA to the mount. Moving the mount to
   /var/lib/postgresql instead would make an existing volume look empty and
   initdb a second, blank cluster beside the old data.

2. The rendered mm-postgres-init.sql embeds both db passwords and is
   root-only (0600), but postgres runs /docker-entrypoint-initdb.d/* after
   dropping to the postgres user: "init.sql: Permission denied", mm_admin
   was never created, and mm-core failed with "password authentication
   failed for user mm_admin". The cluster still counted as initialised, so
   a re-run never retried it. Mount the file at /run/mm-init and copy it in,
   owned by postgres, while the entrypoint is still root. The host file
   stays 0600.

3. lk-jwt-service was pinned to latest-ci, upstream's rolling build of
   main, which now exits unless LIVEKIT_FULL_ACCESS_HOMESERVERS is set.
   Pin the 0.9.0 release (upstream tags releases now) and set the variable
   to this server's name. LIVEKIT_JWT_SERVICE_HOMESERVERS is not an
   upstream setting; it is replaced.

test/fresh_install.bats covers all three, using `[[ ]] || false` so the
checks also fail on macOS /bin/bash 3.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The compose template defaults MM_MONETIZATION_ENABLED to true, and mm-core
refuses to start with payments on and MM_STRIPE_SECRET_KEY or
MM_STRIPE_WEBHOOK_SECRET empty. A plain install (no --demo, no Stripe
account yet) therefore never started mm-core, and the Operator Console
where an operator enters the keys and turns payments on is served by that
same mm-core. --demo had the same hole: nothing generated keys for
mm-fakestripe.

install.sh now seeds MM_MONETIZATION_ENABLED in .env from payments_seed:
true only when both Stripe secrets are set. An explicit
MM_MONETIZATION_ENABLED in the environment still wins. --demo generates a
throwaway sk_test_demo_ key and whsec_demo_ secret (mm-fakestripe signs
its webhooks with the same secret mm-core verifies), and a re-run keeps
them. When payments are left off, the installer says where to turn them on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e failed

Traefik's docker provider was told --providers.docker.network=mm_network,
but compose names the network matrixmedia_mm_network (install.sh always
runs the stack as project `matrixmedia`). A name that matches nothing makes
Traefik pick a network per container at random. Synapse, mm-core, mm-web,
mm-switch and LiveKit are also on `internal`, which Traefik is not on, so
their routes answered 504 after 30 s or worked, depending on the pick. On a
clean server the owner login (/_matrix/client/v3/login) hit the 504 and the
install stopped. Changing only this flag on that server took the Synapse
routes from 504 to 200.

That stop was also silent. Under install.sh's `set -euo pipefail`, an empty
token extraction failed the pipeline and exited before
capture_admin_token's die. Both the curl and the extraction now tolerate
failure, so the die runs and prints what the login answered (never the
password).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a clean server the self-smoke failed on a stack that was fine:

- mm-core paths answered 404. install.sh recreates mm-core to hand it the
  captured admin token and probed straight after; Traefik routes a
  container only once it reports healthy. It now waits for the project to
  be healthy (wait_healthy) and for one mm-core route to answer through
  Traefik (new wait_http) before the smoke, which is unchanged and still
  reports real failures.
- https://<domain>/.well-known/matrix/client answered 404. mm-web serves
  both Matrix well-known files, but Traefik forwarded only /server on the
  apex. Clients use /client to find the homeserver from "@alice:<domain>".
  Verified on the server: 404 before, the m.homeserver JSON after.

Also link mmctl before the stack starts: every failure message from there
on says "run: mmctl …", and the command did not exist until the smoke had
passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run on a clean, healthy install:

- doctor, check and upgrade's post-roll smoke passed MM_SYNAPSE_ADMIN_TOKEN
  first, and mm-core's /_mm/admin/v1/health answers 401 to it. doctor and
  check failed on every healthy install, and every upgrade would have
  "failed" its smoke and printed the restore command. All callers now pass
  MM_ADMIN_TOKEN, as install.sh does; one test covers every caller.
- check died with "check_min: command not found". It lives in
  lib/preflight.sh, which mmctl did not load.
- version printed "mmctl dev" on every piped install (no .git). The piped
  bootstrap now records the ref it downloaded in src/.deploy-ref, and
  version falls back to it.

Verified on the server: doctor "self-smoke passed", check "check OK",
version prints the deployed commit, backup writes config and both
database dumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The closing line said "Sign in at https://matrix.<domain>". On a self-host
that URL answers 403: only the dashboard and viewer are baked into mm-web,
and the root's landing page is an optional mount. The line now gives the
Operator Console (/_mm/dashboard/, where the owner signs in with their
Matrix ID), the homeserver address for Matrix apps, and where the password
is. admin.credentials gains a console= line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The compose template set SUPER_USER: admin. lnbits validates it as a
128-bit (32 hex char) user id and aborts startup ("User ID must have at
least 128 bits"), so the demo profile's lnbits stayed unhealthy and every
--demo install failed its health wait. The same line took lnbits down on
the dev server in August. The comment beside it already describes the
intended bootstrap: the first account created in the web UI becomes the
superuser. The line is removed.

The compose fallback image is now the versions.env pin
(lnbits/lnbits:v1.6.2), not the abandoned lnbitsdocker/lnbits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@argiad
argiad enabled auto-merge (rebase) November 8, 2026 08:42
@argiad
argiad merged commit 13d5a25 into development Nov 8, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant