Operator guide for installing and running Aruma: the Docker setup, the prebuilt binaries, and the Realtime Database behind your own domain, certificates and proxy.
Docker
Section titled “Docker”compose.yaml at the root of the repository runs two containers: db (the official postgres:18 image) and aruma (built from the Dockerfile). Getting started shows the first start.
| What | Where |
|---|---|
| Database files | the pgdata volume |
| Storage objects | the storage volume, mounted at /var/lib/aruma/storage |
| Secrets | .env next to compose.yaml: ARUMA_MASTER_KEY and POSTGRES_PASSWORD |
Optional settings in .env |
ARUMA_PORT (default 8080), ARUMA_PUBLIC_URL, ARUMA_VERSION (the version the image reports) |
The image runs as user aruma (uid 10001), applies pending migrations before serve, and reports its health from /console/v1/status. It holds the PostgreSQL 18 client tools, so backups use a pg_dump that is not older than the server. Any ARUMA_* setting from configuration can be added under environment: in compose.yaml.
The port is bound to 127.0.0.1. To serve other machines, run a TLS proxy (Caddy, nginx) in front, set ARUMA_PUBLIC_URL to its https:// address, and list the proxy in ARUMA_TRUSTED_PROXIES. Firestore clients on iOS and Android need the proxy to speak HTTP/2 to Aruma (h2c), as the Firestore migration guide describes. The Realtime Database’s production-style URLs need the setup in Realtime Database below.
Back up (the database and the Storage volume belong together, take both):
docker compose exec aruma aruma backup --out /tmp/aruma.dumpdocker compose cp aruma:/tmp/aruma.dump ./aruma-$(date +%F).dumpdocker run --rm -v aruma_storage:/data -v "$PWD":/out alpine tar -czf /out/aruma-storage-$(date +%F).tar.gz -C /data .Upgrade: back up, then check out the new tag and rebuild. Migrations run when the server starts.
git fetch --tags && git checkout v0.4.1 # the version you are moving todocker compose up -d --buildPrebuilt binaries
Section titled “Prebuilt binaries”Each release has one archive per system, aruma_<version>_<os>_<arch>, for Linux and macOS (.tar.gz) and Windows (.zip) on amd64 and arm64. The binaries are static: they need no other library, only a PostgreSQL 18 to connect to, and the PostgreSQL 18 client tools on PATH (or in ARUMA_PG_BIN) for aruma backup and aruma restore.
Check what you downloaded. checksums.txt lists the SHA-256 of every archive, and each archive has a build attestation that proves it was built by this repository’s release workflow:
grep aruma_0.4.0_linux_amd64 checksums.txt | sha256sum --check # macOS: shasum -a 256 --checkgh attestation verify aruma_0.4.0_linux_amd64.tar.gz --repo arumadev/arumaNot signed by Apple or Microsoft. macOS blocks a binary downloaded with a browser until you allow it: xattr -d com.apple.quarantine aruma arumactl, or System Settings, Privacy & Security, Open Anyway. Windows SmartScreen may show “Windows protected your PC”; choose More info, then Run anyway. Files fetched with curl are not marked on macOS. Docker avoids both.
Windows. The server, Auth, the Realtime Database and the Document Database run. Cloud Storage does not start on Windows, because it needs the file locks and disk calls Aruma implements for Linux and macOS. Use Docker or WSL for Storage.
Realtime Database
Section titled “Realtime Database”Every SDK finds a database by the first label of its host, as Firebase’s own
<ns>.firebaseio.com does (ADR-018). In production each project’s database therefore has a host
of its own, https://<project_id>-default-rtdb.<rtdb.domain>, and that host needs a DNS name, a
certificate and a proxy that passes it through unchanged. This section sets them up.
1. rtdb.domain
Section titled “1. rtdb.domain”public_url: https://aruma.example.comrtdb: enabled: true domain: rtdb.example.com # ARUMA_RTDB_DOMAINmetrics_listen: 127.0.0.1:9464 # private; the TLS ask below is served heretrusted_proxies: [127.0.0.1/32] # the proxy's address (section 4 below)- Config files and the dashboard then carry
databaseURL: https://<project_id>-default-rtdb.rtdb.example.com. - That URL has no port, so the proxy must serve the database hosts on 443.
rtdb.domaintakes no port. Every SDK does accept a port indatabaseURL(https://<ns>.rtdb.example.com:8443): web, Admin Node, Android, iOS, Admin Go and Admin Python keep the first label as the namespace, and https with a port stays TLS. But you would then have to write that URL into every client yourself. - A dedicated subdomain (
rtdb.example.com, four-label hosts) is the suggested form. A domain of its own (example-db.com) works too, and suits a proxy whose free certificate covers one level only. public_urlandproject_domainmust lie outsidertdb.domain: every host under it belongs to the database.- Without
rtdb.domain, databases are reached as<public_url>/?ns=<project_id>-default-rtdb. That form breaks the iOS, Admin Go and Admin Python SDKs, so it only suits local development onhttp://127.0.0.1; the server warns at start whenpublic_urlis https andrtdb.domainis unset.
2. The wildcard DNS record
Section titled “2. The wildcard DNS record”One record covers every project, present and future:
*.rtdb.example.com. 300 IN A 203.0.113.10*.rtdb.example.com. 300 IN AAAA 2001:db8::10The address is the proxy in front of Aruma. Nothing else is needed per project.
3. Certificates: Caddy with on_demand_tls
Section titled “3. Certificates: Caddy with on_demand_tls”A wildcard certificate needs a DNS-01 challenge and an API key at your DNS provider. Caddy can
instead obtain one certificate per database host the first time a client connects, if Aruma vouches
for the name. Aruma answers GET /rtdb/tls-ask?domain=<name> on metrics_listen only: 200 when the
name is <ns>.<rtdb.domain> and ns the database of an existing, active project, 404 for anything
else, so no one can make Caddy request certificates for names that serve nothing.
{ on_demand_tls { ask http://127.0.0.1:9464/rtdb/tls-ask }}
aruma.example.com { reverse_proxy 127.0.0.1:8080}
*.rtdb.example.com { tls { on_demand } reverse_proxy 127.0.0.1:8080}askmust point at the private address. If Caddy runs on another machine or in a container, bindmetrics_listento an interface only Caddy can reach (a private network), never a public one: the answer tells which projects exist.- The first connection to a new project’s host waits a few seconds for its certificate; the SDKs wait 30 seconds before they give up and retry.
- Caddy keeps each certificate in its storage and renews it on its own: restarting Aruma neither asks again nor issues anything new.
- A name the ask refuses gets no certificate, and the client’s TLS handshake fails (an “internal
error” alert). Caddy logs those refusals only at debug level: a
logblock in the global options withlevel DEBUGandinclude tlsshows “on-demand certificate issuance denied” with Aruma’s 404. - Let’s Encrypt allows 50 new certificates per registered domain per week. Beyond that rate of new projects, or to avoid the first-connection wait, use a wildcard certificate obtained with DNS-01 (a Caddy build with your DNS provider’s module, lego, certbot or acme.sh). Aruma does not care how the certificate was issued.
- Behind Cloudflare’s proxy the free certificate covers one level below the zone: either leave the
*.rtdbrecord DNS only, or use a two-labelrtdb.domainof its own.
4. Proxies: keep the host
Section titled “4. Proxies: keep the host”The database is named by the host the client connected to, and the server tells clients that host in the handshake, where they reconnect. A proxy must therefore either:
- keep the
Hostheader as the client sent it (Caddy’sreverse_proxydoes by default; nginx needsproxy_set_header Host $host;), or - send
X-Forwarded-Hostwith the client’s host, from an address listed intrusted_proxies(ARUMA_TRUSTED_PROXIES). Aruma believesX-Forwarded-HostandX-Forwarded-Foronly from those addresses; from anyone else they are ignored.
A proxy that rewrites the host to 127.0.0.1:8080 without either makes every database request fail
with “Specify the database”. If you set the header yourself in Caddy, use header_up Host {hostport}, not {host}: the handshake hands that value back to the client, and {host} drops a
port other than 443.
List the proxy in trusted_proxies in any case. Without it, every connection seems to come from
the proxy’s address, so the per-address limits (rtdb.limits.connections_per_ip, 500 by default,
and the per-address rate limits of the token, console and Auth endpoints) apply to all your users
together. For Caddy on the same machine that
is 127.0.0.1/32.
5. Timeouts: at least 60 seconds idle
Section titled “5. Timeouts: at least 60 seconds idle”Clients hold WebSockets open for hours and long polls for up to 20 seconds per request. The SDKs
send a keep-alive after 45 seconds of silence and Aruma closes a connection after 120 silent
seconds, so every hop between them must allow at least 60 seconds of idle time on a connection,
and must pass WebSocket upgrades (Upgrade and Connection headers).
- Caddy: nothing to set.
reverse_proxystreams WebSockets with no idle limit, and waits for a long poll’s answer with no limit either. Caddy closes every proxied WebSocket when its config is reloaded, unless you setstream_close_delay(for examplestream_close_delay 5minsidereverse_proxy). That way a reload does not make every client reconnect at the same moment. - nginx:
location / {proxy_pass http://127.0.0.1:8080;proxy_http_version 1.1;proxy_set_header Host $host;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection $connection_upgrade; # map $http_upgrade $connection_upgradeproxy_read_timeout 120s;proxy_send_timeout 120s;}
- Load balancers (AWS ALB, Google Cloud, HAProxy
timeout tunnel): raise the idle timeout to 120 seconds or more. - Aruma’s own 60-second request timeout and 1 MB body cap do not apply to database connections.
6. Capacity: descriptors and the database pool
Section titled “6. Capacity: descriptors and the database pool”Measured on the reference server:
- Each connection holds one file descriptor and about 40-45 KiB of memory (two goroutines, their
stacks, and the session), plus about 5 KiB per listen. The process’s descriptor limit therefore
caps the connections: raise it (
LimitNOFILE=in a systemd unit,ulimit -n,--ulimit nofilein Docker). On stock macOSkern.maxfilesperprocstops one process at about 10,200 connections. - Each busy database holds a Postgres connection while its writer commits a batch. When more
databases write at once than the pool has connections, they queue for one: ten busy databases on
the default pool (max(4, CPUs)) wrote about 40% less than with
pool_max_conns=32. Size the pool above the number of databases that write at the same time, indatabase_url:postgres://…/aruma?pool_max_conns=32, within Postgres’smax_connections.
7. Check it
Section titled “7. Check it”curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:9464/rtdb/tls-ask?domain=chat-app-default-rtdb.rtdb.example.com' # 200curl -s -o /dev/null -w '%{http_code}\n' 'https://chat-app-default-rtdb.rtdb.example.com/.settings/rules.json' # 403 without a credential: the host reaches the databasecurl -s 'https://no-such-default-rtdb.rtdb.example.com/' # fails in the TLS handshake: no certificate for unknown names