Install the suite, for real, in containers

The plan now happens. "inbuxa install --local --domain example.test
--install-deps --yes" on a machine with nothing on it ends with a mail
server, a console and a webmail running, an administrator and a first
mailbox created, and the records the domain needs written out.

The sequence is the one ihasmail-oneshot worked out against a running
server, which is why its JMAP client and its Docker handling came across
nearly whole: bring the server up in bootstrap mode with a credential that
lives in an override file for that step only, complete bootstrap, bring the
rest up without it -- so no recovery credential outlives the setup -- exempt
the front ends from the auto-ban, restart for the settings that need it,
create the first account, and write down the password nothing else holds.

New here: three services rather than two. The console is static files that
learn their server's address at start, and the webmail is given the
first-party OAuth client secret that the server is given too.

Twenty checks in the lab, from a bare Debian 13. The two worth having are
the ones that catch an install that looks fine and is not: nothing in the
running server carries a recovery admin any more, and the account the
installer created can sign in to the webmail it installed.

Two bugs the lab caught, both of which would have shipped:

- the private addresses were worked out on a copy of the stack, so the
  server was told the webmail speaks from "", and refused it.
- the console image rewrites index.html when it starts, so a read-only root
  filesystem left it restarting forever. The webmail keeps read_only; the
  console cannot have it until that rewrite moves.
This commit is contained in:
2026-09-22 18:26:31 -07:00
parent cc77f62c01
commit 7141e565ea
12 changed files with 1765 additions and 7 deletions
@@ -0,0 +1,136 @@
# Written by the inbuxa installer {{.Version}} for {{.Domain}}.
#
# This is the whole deployment. Bring it up again with `docker compose up -d`
# from this directory. Secrets are in .env beside it and the administrator's
# password is in credentials.txt -- both readable only by root.
#
# The mail server's plain HTTP port is published on loopback only and reached
# over the private network below, which is why the front ends talk to it as
# http://: that leg never leaves this machine.
name: {{.Project}}
services:
server:
image: {{.ServerImage}}
hostname: {{.MailHost}}
restart: unless-stopped
ports:
- "{{.ServerBind}}:8080"
{{- range .MailPorts}}
- "{{.}}:{{.}}"
{{- end}}
volumes:
- inbuxa-etc:/opt/stalwart/etc
- inbuxa-data:/opt/stalwart/data
{{- if .CABundle}}
# The system roots plus the private ACME CA, so the server can reach it.
- ./ca-bundle.crt:/etc/ssl/certs/ca-certificates.crt:ro
{{- end}}
environment:
# The front ends, which is what registers their OAuth clients and sets
# the cross-origin allowlist (contract C-6 and C-14). The webmail's
# secret is generated by the installer and given to both sides.
{{- if .ConsoleURL}}
INBUXA_ADMIN_URL: {{.ConsoleURL}}
{{- end}}
{{- if .WebmailURL}}
INBUXA_WEBMAIL_URL: {{.WebmailURL}}
INBUXA_WEBMAIL_CLIENT_SECRET: ${WEBMAIL_CLIENT_SECRET:?WEBMAIL_CLIENT_SECRET is missing from .env}
{{- end}}
networks:
stack:
ipv4_address: {{.ServerIP}}
{{- if .Console}}
console:
image: {{.ConsoleImage}}
restart: unless-stopped
depends_on: [server]
# Not read-only, unlike the webmail: this image writes the api-base-url
# into index.html when it starts, which is what lets one image serve any
# installation. A read-only root makes it restart forever instead.
tmpfs: [/tmp, /var/cache/nginx, /var/run]
ports:
- "{{.ConsoleBind}}:8080"
environment:
# Written into index.html at start, so one image serves any
# installation. The browser talks to the server directly from here.
API_BASE_URL: {{.ServerPublicURL}}
networks:
stack:
ipv4_address: {{.ConsoleIP}}
{{- end}}
{{- if .Webmail}}
webmail:
image: {{.WebmailImage}}
restart: unless-stopped
depends_on: [server]
# Immutable: read-only root, no volume, sessions in memory. A restart
# signs everyone out; nothing else is lost, because nothing else is kept.
read_only: true
tmpfs: [/tmp]
ports:
- "{{.WebmailBind}}:8080"
environment:
MAIL_SERVER_URL: http://server:8080
APP_SECRET: ${APP_SECRET:?APP_SECRET is missing from .env}
APP_NAME: inbuxa
IMMUTABLE: "1"
SESSION_FILE: ""
TRUST_PROXY: "1"
IMAGE_PROXY: "1"
{{- if not .Local}}
# Sign-in goes through the mail server's own page, as the first-party
# client the server registered for this URL.
OAUTH_CLIENT_ID: ihasmail-inbuxa
OAUTH_CLIENT_SECRET: ${WEBMAIL_CLIENT_SECRET:?WEBMAIL_CLIENT_SECRET is missing from .env}
PUBLIC_URL: {{.WebmailURL}}
{{- if .ConsoleURL}}
ADMIN_URL: {{.ConsoleURL}}
{{- end}}
# The server pushes changes here instead of holding a connection per
# tab. If it cannot reach it, every tab falls back to the relay.
PUSH_URL: {{.WebmailURL}}
{{- end}}
networks:
stack:
ipv4_address: {{.WebmailIP}}
{{- end}}
{{- if .Proxy}}
caddy:
image: {{.CaddyImage}}
restart: unless-stopped
depends_on: [server]
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
{{- if .ACMECARoot}}
- ./acme-ca-root.pem:/etc/caddy/acme-ca-root.pem:ro
{{- end}}
networks:
stack:
ipv4_address: {{.CaddyIP}}
{{- end}}
networks:
stack:
ipam:
config:
- subnet: {{.Subnet}}
volumes:
inbuxa-etc:
inbuxa-data:
{{- if .Proxy}}
# Certificates and the ACME account. Losing this means asking for every
# certificate again, which is how rate limits are reached.
caddy-data:
caddy-config:
{{- end}}