Self-hosting a RIM server
deploy/compose.yml runs one RIM server on one domain: rim-server with RIM_ROLE=both (it
hosts workspaces and is its users' relay), PostgreSQL 17, the web client, and Caddy terminating
TLS. Caddy sends /api (with /api/ws), /.well-known/… and /s2s/… to rim-server and every
other path to the web client, so the client and the API share one origin. The health probes
stay inside the compose network.
A public relay is the same file with deploy/compose.relay.yml on top; deploy/README.md
covers it.
Everything marked FILL is the operator's choice, recorded here once made.
Before the first start #
A Linux host with Docker (Compose v2.20 or later), ports 80 and 443 reachable from the internet, and DNS
A/AAAArecords of your domain pointing at it. Caddy gets the certificate from Let's Encrypt on the first request.This repository at the release you run. The compose files, the Caddyfile and the backup scripts come from the checkout; the images are pulled:
git clone https://github.com/rim-platform/rim-server.git cd rim-server git checkout v<version>The compose files run
ghcr.io/rim-platform/rim-serverandghcr.io/rim-platform/rim-webatRIM_VERSIONfromdeploy/.env; set it to the same<version>. Left unset it islatest, which moves with every release. To build both images on the host instead, check out rim-ui next to this repository and add-f deploy/compose.dev.ymlto every compose command (the alias below) withup --build.deploy/.envfrom the example, filled in:cp deploy/.env.example deploy/.env chmod 600 deploy/.env sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=')|" deploy/.env $EDITOR deploy/.env # RIM_VERSION, RIM_DOMAIN, and SMTP for password resets and invitesEvery variable the compose files read is in
.env.examplewith what it does. WithoutPOSTGRES_PASSWORDorRIM_DOMAINevery compose command stops and names the variable.Check the configuration:
alias dc='docker compose -f deploy/compose.yml' dc config >/dev/null && echo ok
The commands below use that alias, from the repository root.
Verify the images #
The release workflows sign every image with cosign keyless. The certificate names the repository's release workflow and the tag it ran for, so an image built or signed anywhere else does not verify. Before the first start and after every upgrade:
v=0.0.1 # your RIM_VERSION
dc pull
cosign verify ghcr.io/rim-platform/rim-server:$v \
--certificate-identity https://github.com/rim-platform/rim-server/.github/workflows/release.yaml@refs/tags/v$v \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
cosign verify ghcr.io/rim-platform/rim-web:$v \
--certificate-identity https://github.com/rim-platform/rim-ui/.github/workflows/release.yaml@refs/tags/v$v \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
cosign prints the digest it verified, which is also in the GitHub release notes. Setting
RIM_SERVER_IMAGE and RIM_WEB_IMAGE in deploy/.env to …@sha256:<digest> makes compose run
exactly those images. Each release also carries the image's SBOM (SPDX JSON), attested to the
image: cosign verify-attestation --type spdxjson with the same flags checks it.
The web client's endpoint #
The web client finds the API through VITE_GRAPHQL_ENDPOINT, baked in when its image is built.
The published image is built with /api, and deploy/compose.dev.yml passes
RIM_WEB_GRAPHQL_ENDPOINT, default /api: the client resolves it against the page's own
address, so one image serves any domain and needs no CORS. The image's
content security policy allows connect-src 'self', which covers the API and its WebSocket on
that origin; the browser console notes that it ignores the extra /api source the image adds.
https://<your domain>/api gives the same result without that note and ties the image to the
domain.
Browsers send an Origin header with the client's requests even on one origin. The server
accepts its own origin without configuration: that of RIM_PUBLIC_BASE_URL when set, else the
scheme and Host the request arrived with (X-Forwarded-Proto and X-Forwarded-Host count
only with RIM_TRUST_FORWARDED_HEADERS=true). Any other origin gets 400 Origin is not allowed, with two exceptions: the packaged desktop app's origins (tauri://localhost on macOS
and Linux, http://tauri.localhost on Windows), which no web page can carry, and one extra
origin named by RIM_CORS_ALLOWED_ORIGIN, such as a client served from another domain or the
desktop development app at http://localhost:1420. The compose file leaves it as a commented
example.
First start #
dc up -d
dc logs -f rim
The migrate service applies the migrations and exits; the server starts after it. dc ps
shows rim as healthy once /readyz passes.
The server recovery key #
On its first start the server creates its key and writes the recovery key to
/data/server-recovery-key.txt, and it logs a warning at every start until the file is gone.
The recovery key replaces a lost or stolen server key; whoever holds it can make any key this
server's, so it never stays on the host:
dc exec rim cat /data/server-recovery-key.txt
# store the line offline: a password manager and a printed copy, held by two people
dc exec rim rm /data/server-recovery-key.txt
dc exec rim /app/rim-server server-key show names the recovery key's public half, so a copy can
be checked without using it. Backups never include the file (below). docs/server-identity.md
has the key commands, rotation and recovery.
The first account #
Signups are invite-only by default. Issue a single-use link for your own account; it works for
7 days (--expires-in-days changes that):
dc exec rim /app/rim-server invite create --public-url https://rim.example.org
--email you@example.org binds the link to that address: a key signup with it gives the new
account that address, and a password signup must give that address.
Open the link it prints (https://rim.example.org/join/…) and sign up. For other people, a link
for up to 50 signups that lasts up to 30 days:
dc exec rim /app/rim-server invite create --max-uses 10 --expires-in-days 7 --public-url https://rim.example.org
dc exec rim /app/rim-server invite list
dc exec rim /app/rim-server invite revoke <id>
The owner of a workspace can also invite from the app. RIM_OPEN_REGISTRATION=true lets
anyone sign up, rate-limited per address; RIM_REGISTRATION_POW_BITS asks a proof-of-work of
signups without an invite.
New accounts sign up with a key: the app creates the account's identity on the device that signs
up and shows its recovery kit. RIM_SIGNUP_REQUIRE_KEYS (commented out in deploy/.env.example,
on unless set to false) refuses every password signup, whatever the registration mode;
password accounts that already exist still sign in. Set it to false only for clients that
cannot sign up with a key.
Check it answers #
dc exec rim curl -fsS http://localhost:8080/readyz; echo
curl -s https://rim.example.org/.well-known/rim-server | head -c 300; echo
curl -sI https://rim.example.org/ | head -n 1 # the web client
dc exec rim /app/rim-server --version
Updates #
Take a backup first (below): migrations only go forward, so a backup is the way back.
deploy/backup.sh
git fetch --tags && git checkout v<version>
$EDITOR deploy/.env # RIM_VERSION=<version>
dc pull # then verify them, as above
dc up -d
dc logs -f rim
up runs migrate before it starts the new server. To run the migrations alone,
dc run --rm migrate. Rolling the image back leaves the migrations in place: the previous
release runs on them, started with dc up -d --no-deps rim web, and anything further back is the
backup taken before the update. docs/operations/upgrade.md has the whole procedure, what to
read in CHANGELOG.md first, and the rollback.
Backups #
deploy/backup.sh writes one archive, rim-backup-<project>-<UTC time>.tar, under
RIM_BACKUP_DIR (default ./backups, which Git ignores). It holds:
| File | What |
|---|---|
rim.dump |
pg_dump -Fc of the database, checked to read back |
rim-data.tgz |
the rim_data volume: server key, franking keys, uploads, the audit log and audit exports |
env |
deploy/.env |
MANIFEST, SHA256SUMS |
project, time, server version, checksums |
The recovery key file is never in it; the script warns while it is still on the host. The database and the data volume are one set: the server key file must match the key log in the database, and the server refuses to start on a mixed set. Never restore one without the other.
With RIM_BACKUP_AGE_RECIPIENT set (an age1… recipient or a file of recipients), the archive
is encrypted with age to ….tar.age and nothing unencrypted is
left on disk. Keep the identity that decrypts it off the host, with the recovery key's holders.
deploy/backup.sh
COMPOSE_FILE=deploy/compose.yml:deploy/compose.relay.yml deploy/backup.sh # a relay
The scripts read COMPOSE_FILE and COMPOSE_PROJECT_NAME the way docker compose does,
RIM_ENV_FILE for another env file, and the RIM_BACKUP_* settings from the environment or
deploy/.env.
How often. Nightly, and before every update or key rotation. A cron entry on the host:
15 3 * * * cd /srv/rim-server && deploy/backup.sh >>/var/log/rim-backup.log 2>&1
Where copies go. Off the host, encrypted: FILL the destination (object storage, another machine) and how long copies are kept. The archive holds the server's private key and every user's data; treat it like the host itself.
Restore #
deploy/restore.sh restores an archive into a stopped stack: it drops and recreates the
database from the dump, empties the data volume and refills it, starts the stack and waits for
/readyz. It asks for the project name before it replaces anything (RIM_RESTORE_YES=1 skips
that).
dc stop rim
deploy/restore.sh backups/rim-backup-rim-20261010T031500Z.tar
RIM_BACKUP_AGE_IDENTITY=~/rim-backup.key deploy/restore.sh backups/….tar.age
On a new host, check out the repositories, take deploy/.env from the archive's env file, build
or pull the images, and run the same command. A newer image applies its migrations to the
restored database when it starts.
The restore drill #
A backup is good once it has been restored. deploy/restore-drill.sh restores the newest archive
(or the one given) into a throwaway project, rim-drill, waits for /readyz, prints the server
key and the row counts of a few tables, and removes the project and its volumes. The drill's
server has server-to-server traffic and mail off and publishes no port, so it never speaks for the
real one; it runs next to the live stack on the same host.
deploy/restore-drill.sh
deploy/restore-drill.sh backups/rim-backup-rim-20261010T031500Z.tar
Run it after the first backup, every quarter, and after changing the backup setup. Compare the
server id and key seq with dc exec rim /app/rim-server server-key show and the row counts with
production's. Record each drill: FILL where.
Monitoring #
Probes. rim-server answers
/healthz(200 while the process runs) and/readyz(200 when the database answers, no migration is pending and the key files are readable, else 503 naming the failing checks) on its own port, without a session. They are for Docker and the compose network: therimservice's healthcheck uses/readyz, and Caddy answers both paths with 404. From the host:dc ps # rim, postgres and web: healthy dc exec rim curl -fsS http://localhost:8080/readyz; echo dc exec rim curl -fsS http://localhost:8080/healthz; echoAn outside uptime check watches the public origin instead, for example
https://<domain>/.well-known/rim-serverand the web client's/.Metrics. Uncomment
RIM_METRICS_ADDR: 0.0.0.0:9090in therimservice and run Prometheus in a container on the compose project'sinternalnetwork, scrapingrim:9090. Never publish the port or route it through Caddy.docs/operations/metrics.mdlists the metrics and what to alert on.Logs. JSON on stdout:
dc logs --since 1h rim. Audit events ("target":"audit") also go to/data/audit.log;docs/operations/audit-retention.mdcovers its rotation andRIM_AUDIT_RETENTION_DAYSfor the audit table.Background jobs.
docs/operations/background-jobs.mdlists what the server runs on a timer, how often, and the setting that changes it.Disk. The
postgres_dataandrim_datavolumes, andRIM_BACKUP_DIR.
Limits and blocking #
A server open to the internet can limit what one client sends: requests per address, mutations
per session, messages per user, and WebSocket connections and subscriptions. Every limit is off
until its RIM_RATE_* variable is set in deploy/.env (the "Request limits" block of
deploy/.env.example); docs/api.md ("Limits") says what clients see past each one. An
address or range that keeps misbehaving is blocked outright with
dc exec rim /app/rim-server ip block 203.0.113.0/24 --reason "scraping" --expires-in-days 30:
it gets 403 {"error":"blocked"} on every route but /healthz, server-to-server routes
included. Leave out --expires-in-days to block until ip unblock; ip list shows every block
with its expiry. A running server reads the list every 30 seconds, so a change applies within
that; each block and unblock is written to the audit log as ip.block or ip.unblock. Behind
Caddy the address is the client's, since RIM_TRUST_FORWARDED_HEADERS is on; behind another
proxy it must replace X-Forwarded-For (next sections), or one forged header unblocks a client.
Instance limits #
A public instance can also cap how much each person holds: workspaces per user, members and
channels per workspace, devices per account, DM requests to strangers per day, macros,
pipelines and plugins per workspace, invites per user per day, uses per invite and the bytes
of a message. Each is a RIM_LIMIT_* variable, off until set (the "Instance limits" block of
deploy/.env.example has values for a small test instance); past one a write is refused with
a message naming the cap, and clients read every cap from serverInfo.limits. Devices and DM
requests also follow the relay quotas RIM_RELAY_DEVICES_PER_USER and
RIM_RELAY_DM_REQUESTS_PER_DAY: the stricter number applies. A negative or non-numeric value,
or RIM_LIMIT_MESSAGE_BYTES above 65536, stops the server at boot. One cap is on by default:
RIM_LIMIT_PENDING_INVITES_PER_USER (50, 0 turns it off) bounds the invitations to encrypted
conversations one person holds, so nobody can be buried in them.
RIM_PROBATION_HOURS slows down accounts made to spam: for that many hours after its creation
an account cannot create a workspace or send a DM request to someone it shares no workspace
with and has not added as a contact, and with RIM_RATE_PROBATION_MESSAGES_PER_MIN it sends
messages at that lower rate. Existing accounts are judged by their age, so turning it on does
not hold back anyone older than the setting.
Optional features #
Attachments, link previews, email, custom emoji, workspace plugins and the AI command surface
can be switched off with RIM_FEATURES_DISABLED (commented out in the rim service);
docs/operations/features.md lists what each one covers and what clients see.
RIM_LINK_PREVIEW_CONCURRENCY (default 8) caps the link preview fetches running at once.
Outgoing requests ignore proxy environment variables.
Notice and terms #
RIM_INSTANCE_NOTICE is a short text, at most 500 characters, that clients show people on
your server, such as who runs it and how to reach you. RIM_TERMS_URL is the address of your
terms of service; once it is set, every signup has to accept those terms and the server records
on the account when it did and which URL it accepted. Both are commented out in
deploy/.env.example and reach the rim service when set; clients read them from
serverInfo. A notice over 500 characters or a terms URL that is not an absolute http(s) URL
stops the server at boot. Changing the terms URL does not ask existing accounts to accept again.
RIM_RESERVED_NAMES (commented out in deploy/.env.example) lists usernames and display names
that signups and profile edits refuse, ignoring case, so nobody can pose as your maintainers;
set it once their accounts exist, since it refuses those names to new signups too.
A default workspace #
RIM_DEFAULT_WORKSPACE (commented out in deploy/.env.example) names a workspace on this
server that every new account joins as a Member, whichever way it signs up: open,
proof-of-work, with an instance invite or a workspace invite, with a key or a password. The
account joins as soon as it exists and gets the workspace's public channels like any new member,
so people land where others already are instead of in an empty direct space.
serverInfo.defaultWorkspace names the workspace, so a client can open it after a signup.
Unset, nothing changes: a new account joins only the workspace its invite names.
Create the workspace in the app, look its id up, set it in deploy/.env and run dc up -d:
dc exec -T postgres psql -U rim -d rim -c "SELECT id, name FROM server WHERE kind = 'workspace'"
A value that is not a workspace id, a workspace that does not exist on this server, and the
direct space each stop the server at boot. Accounts that existed before are not added; invite
them. An account already in the workspace, or banned from it, is left as it is.
RIM_LIMIT_MEMBERS_PER_WORKSPACE does not keep a new account out of the default workspace, but
an invite to it still follows the cap. A workspace deleted while the server runs stops the joins
until the setting changes, and the next start refuses its id. The workspace's channels are
ordinary plaintext channels; DMs and encrypted groups are untouched, and other servers see an
ordinary membership.
Who may start a DM #
RIM_DM_INBOUND, RIM_DM_OUTBOUND and RIM_DM_PARTNERS (commented out in
deploy/.env.example) set floors for every account: by default a stranger's first message is a
request the recipient accepts, and people here may start DMs with anyone. A company closes its
server with none, or opens it to named partner servers with partners
(docs/operations/closed-company-server.md). Workspace owners and each person tighten further
in the app ("Who May Start A DM" in docs/messaging.md). A value the server does not take stops
it at boot.
Deleted accounts #
People delete their own account from the client. The account is purged once a grace period
ends, RIM_ACCOUNT_DELETION_GRACE_DAYS days after the request (default 14, 0 to 30; 0 purges at
the next hourly run; commented out in deploy/.env.example); until then the person can cancel.
The purge removes the account's sessions, devices and keys, memberships, plaintext messages with
their files, its avatar, settings and personal automations, and deletes the workspaces where it
was the only member. What remains is a tombstone row with the same id, named deleted- and the
start of the id, shown as "Deleted account", with no email, password or key: it cannot sign in,
shows in no member list or search, and stays the author of the account's encrypted messages
(their ciphertext is kept), of messages others replied to (shown as deleted), and of the
workspace pipelines it created. It keeps the account's key identity id, so its revoked devices
still leave encrypted groups, and the same key identity cannot sign up on this server again.
Audit rows are kept until audit retention removes them, and the
purge writes account.purged to the audit log. dc exec rim /app/rim-server account purge --dry-run shows what the next run would remove; without --dry-run it runs the purge now.
Another proxy #
Caddy is an example. Any TLS proxy works if it:
- sends
/api(with/api/wsas a WebSocket upgrade),/.well-known/…and/s2s/…torim:8080unchanged, and the rest toweb:80; - keeps
/healthzand/readyzoff the public side; - accepts bodies up to 32 MiB;
- sets
X-Forwarded-Foritself, replacing what the client sent; - is the only way in.
deploy/README.md has an nginx example for the relay's paths.