Self-Hosting Guide
This guide covers a production Kutup deployment using Docker Compose.
Prerequisites
Section titled “Prerequisites”- Docker 24+ and Docker Compose v2 (
docker composecommand, notdocker-compose) - A Linux server with at least 1 GB RAM
- A domain name (required for HTTPS and for federation to work correctly)
The checked-in single-node Compose topology is convenient for evaluation, but
its SeaweedFS filer uses the upstream embedded LevelDB store inside the filer
container. Before treating it as production, move filer metadata to a durable
mounted directory or an external supported filer store and test a complete
restore. Until then, never remove/recreate that container without the
/filerldb2 backup described below. SeaweedFS documents the embedded-store
default in its
filer command source.
Step 1: Clone and Configure
Section titled “Step 1: Clone and Configure”git clone https://github.com/kutupbt/kutup.gitcd kutupcp .env.example .envEdit .env and fill in every value:
# PostgreSQL — names have checked-in defaults; use a strong passwordPOSTGRES_DB=kutupPOSTGRES_USER=kutupPOSTGRES_PASSWORD=<strong-random-password>
# JWT secret — generate with:# openssl rand -hex 64JWT_SECRET=<64-byte-hex-string>
# SeaweedFS S3 credentials — injected into every bundled service by ComposeS3_ACCESS_KEY=kutupS3_SECRET_KEY=<strong-random-secret>S3_BUCKET=kutup-filesS3_REGION=us-east-1
# Public URL — published as the federation API base# Must be the address users (and remote servers) reach this instance atSERVER_URL=https://kutup.example.com
# Stable account-address suffix. Set it once before accounts are created.# If federation is enabled, it must match FEDERATION_SERVER_NAME.CHAT_SERVER_NAME=kutup.example.com
# Unified federation v2 identity used by both Chat and Drive:# openssl rand -base64 32# FEDERATION_SERVER_NAME=kutup.example.com# FEDERATION_SIGNING_KEY=<base64-32-byte-ed25519-seed># FEDERATION_NEXT_SIGNING_KEY=<set only during authenticated key rotation>
# Active Chat installations per account. V1 permits 1..=10.CHAT_MAX_ACTIVE_DEVICES=10CHAT_MEDIA_MAX_PLAINTEXT_BYTES=2147483648
# Optional contacts-only sealed sender. The policy contains public offline roots# and root-signed online certificates; the normal server receives only the# active online private key.# CHAT_SEALED_SENDER_POLICY=<canonical one-line JSON># CHAT_SEALED_SENDER_ONLINE_PRIVATE_KEY=<base64-32-byte-libsignal-private-key>
# Private MLS groups are advertised only when both authenticated values are# complete and Chat is enabled by the shared federation policy.# CHAT_MLS_ORDERING_POLICY=<canonical authenticated policy JSON># CHAT_MLS_CONTROL_SIGNING_KEY=<base64 signing seed>
# Break-glass admin bootstrap: a single email:username:password triple.# Created on first start; the admin completes setup on first login.# This account is the protected break-glass admin — it can never be# demoted, disabled, or deleted. Promote further admins inside the app.ADMIN_ACCOUNT=admin@example.com:admin:<strong-admin-password>
# SeaweedFS master — the admin dashboard probes it for real storage# capacity + usage. Default works for the bundled compose.SEAWEEDFS_MASTER_URL=http://seaweedfs-master:9333
# Optional fallback storage capacity (bytes) for the admin UI, used only# when the SeaweedFS probe is unavailable. Unset / 0 hides the readout.# STORAGE_TOTAL_BYTES=536870912000
# Days a trashed file/folder stays restorable before the hourly sweeper# purges it permanently. 0 disables the automatic purge (trash only# empties when users do it themselves). Default: 30.# TRASH_RETENTION_DAYS=30
# Chat mailbox, temporary media-delivery, send-id retention, and inactive-device# expiry. The hourly maintenance job enforces these; 0 disables an individual policy.# CHAT_MAILBOX_RETENTION_DAYS=30# CHAT_SEND_RETENTION_DAYS=30# CHAT_MEDIA_DELIVERY_RETENTION_DAYS=45# CHAT_DEVICE_EXPIRY_DAYS=90
# Rate limits (defaults shown). Most are per client IP; chat key fetches use a# primary per-account budget plus a coarse IP outer wall. The backend resolves the# client IP from the proxy-set X-Real-IP header, so keep the backend# unreachable except through nginx.# RATE_LIMIT_LOGIN_PER_MIN=10# RATE_LIMIT_PREFLIGHT_PER_MIN=20# RATE_LIMIT_REGISTER_PER_HOUR=10# RATE_LIMIT_RECOVERY_PER_HOUR=5# RATE_LIMIT_FED_USERS_PER_MIN=60# RATE_LIMIT_ADMIN_PER_MIN=120# RATE_LIMIT_CHAT_KEYS_PER_MIN=30# RATE_LIMIT_CHAT_KEYS_IP_PER_MIN=120
# Optional OTLP/gRPC traces and metrics. Leave all endpoints unset for# logs-only operation. Configure one shared endpoint, or both signal-specific# endpoints; a partial exporter configuration fails startup.# OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com:4317# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://collector.example.com:4317# OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://collector.example.com:4317# OTEL_SERVICE_NAME=kutup-server
# Per-account login lockout: this many failed password attempts lock the# email out for the cooldown. Locked attempts return 429; the lock clears# on its own. Defaults shown.# LOGIN_LOCKOUT_THRESHOLD=5# LOGIN_LOCKOUT_MINUTES=15.env.example is the complete checked-in variable template. Keep
CHAT_SERVER_NAME, FEDERATION_SERVER_NAME, and the public host in
SERVER_URL aligned before creating accounts; changing an established account
address or federation identity is a protocol migration, not a DNS-only edit.
CHAT_MAILBOX_RETENTION_DAYS and CHAT_MEDIA_DELIVERY_RETENTION_DAYS are startup
fallbacks. An administrator can change both at runtime under Admin → Settings →
Chat storage; persisted values take precedence without a server restart.
Mailbox retention covers unread Direct and MLS delivery ciphertext. Media retention
covers only temporary delivery copies and never deletes protected history-media
copies.
Chat uses a dedicated quota, separate from Drive/general storage. It defaults to 2 GiB for new accounts and covers message-history ciphertext, ordinary delivery media, and protected history media. Change the default or an individual account under Admin → Settings → Chat storage; persisted admin settings take precedence without a restart. Lowering a quota below current use preserves reads and blocks new charged work rather than evicting history.
The ordinary account UI reports storage as a read-only progress meter with used and total values. It is not a quota-management control: only an authorized administrator changes Drive or Chat quota policy from Admin settings.
OpenTelemetry
Section titled “OpenTelemetry”The backend can export security-path traces and metrics to an OTLP/gRPC
collector. Set OTEL_EXPORTER_OTLP_ENDPOINT for a shared collector endpoint,
or set both OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT. If no endpoint is set, the server keeps
normal structured logs without installing an exporter. Once export is
configured, an incomplete or invalid exporter setup is a startup error rather
than a silent fallback.
The Chat security instruments cover authenticated policy lifecycle, monitor freshness, proof sizes and outcomes, certificate issuance, sealed-send outcomes, and limiter rejection. Their attributes are bounded outcome or feature classes; usernames, account/device identifiers, send IDs, capabilities and hashes, certificates, ciphertext, and sender-recipient correlations are never metric labels or trace fields.
Step 2: Start the Stack
Section titled “Step 2: Start the Stack”Compose injects S3_ACCESS_KEY and S3_SECRET_KEY from .env into SeaweedFS,
the bucket initializer, and the backend. No second credential file needs to be
edited or kept in sync.
The bundled Nginx already requires TLS. Before the first start, place a
certificate and key at nginx/certs/fullchain.pem and
nginx/certs/privkey.pem. For local evaluation only, generate a self-signed
localhost certificate:
mkdir -p nginx/certsopenssl req -x509 -nodes -newkey rsa:2048 -days 365 \ -keyout nginx/certs/privkey.pem -out nginx/certs/fullchain.pem \ -subj /CN=localhost -addext subjectAltName=DNS:localhost,IP:127.0.0.1Use the public-domain certificate procedure below for production. Then start and wait for every health check:
docker compose up -d --build --waitThis builds the backend and frontend images, then starts all services:
| Service | Role |
|---|---|
postgres |
Database |
seaweedfs-master |
SeaweedFS master node |
seaweedfs-volume |
SeaweedFS volume server |
seaweedfs-filer |
SeaweedFS filer |
seaweedfs-s3 |
SeaweedFS S3 gateway |
seaweedfs-init |
One-shot: creates the S3 bucket |
backend |
Rust API server (Axum, internal port 3000) |
frontend |
Compiled React app (served by Nginx) |
nginx |
TLS reverse proxy — host port 38080 redirects to HTTPS on 38443 by default |
Step 3: First Login
Section titled “Step 3: First Login”Find the admin password confirmation in the backend logs:
docker compose logs backend | grep -i "admin\|bootstrap"Open https://localhost:38443 for the default local mapping (or your public
HTTPS domain) and log in. A self-signed local certificate produces a browser
warning. You will be redirected to a first-login setup page where you must:
- Generate your recovery phrase (BIP39 mnemonic) — write it down and store it safely.
- Optionally configure 2FA.
The recovery phrase is the only way to recover your account if you forget your password. It is never sent to the server.
TLS / HTTPS
Section titled “TLS / HTTPS”The checked-in Nginx configuration already redirects HTTP to HTTPS and serves the application only from its TLS server block. Certificate files are mounted read-only from:
nginx/certs/├── fullchain.pem # Certificate chain└── privkey.pem # Private keyNginx cannot become healthy when either file is missing or invalid. After renewing or replacing them, reload Nginx:
docker compose exec nginx nginx -s reloadUsing Certbot (Let’s Encrypt)
Section titled “Using Certbot (Let’s Encrypt)”Obtain the initial certificate before starting the Compose Nginx, or stop it so Certbot’s standalone listener can own ports 80/443. Copy the live material into the mounted directory, restrict the private key, then start/reload the stack:
# On the host (not inside Docker)certbot certonly --standalone -d kutup.example.com
# Copy into nginx/certs/mkdir -p nginx/certscp /etc/letsencrypt/live/kutup.example.com/fullchain.pem nginx/certs/cp /etc/letsencrypt/live/kutup.example.com/privkey.pem nginx/certs/chmod 600 nginx/certs/privkey.pemdocker compose up -d --build --waitThe Compose file uses development host ports 38080 and 38443. A production
deployment can map 80:80 and 443:443, or keep the loopback mappings and use
an existing edge proxy as described below.
SERVER_URL
Section titled “SERVER_URL”SERVER_URL must be set to the externally reachable base URL of your instance, including the scheme:
SERVER_URL=https://kutup.example.comWhen chat federation is configured, this value is published as the delegated
apiBase. If it is wrong, cross-server sharing and chat routing will not work.
The unified v2 stack uses FEDERATION_SERVER_NAME and
FEDERATION_SIGNING_KEY. The name is the stable suffix in
username@server; no alias namespace is created. The stack persists a
self-signed genesis document and refuses startup if the configured seed is
silently changed. Account manifests are signed by clients and need no separate
server transparency seed. Production
federation requires public HTTPS and rejects loopback, private, link-local, and
other non-public resolved addresses; redirects are disabled.
To rotate the federation identity, keep the current seed in
FEDERATION_SIGNING_KEY, set a distinct FEDERATION_NEXT_SIGNING_KEY, stop
other replicas, and run:
docker compose run --rm backend federation-identity rotateThe command verifies and dual-signs one transition and is safe to retry. Then
move the new seed into FEDERATION_SIGNING_KEY, remove
FEDERATION_NEXT_SIGNING_KEY, and restart every replica. Losing the current
seed does not authorize replacement; remote peers will quarantine a competing
history and require an explicitly confirmed break-glass re-pin.
Federation is unavailable until both generic identity variables are set. Back up the signing seed: losing it does not authorize silent replacement, and remote servers will quarantine a conflicting history.
After configuring the identity, manage the unified control plane in Admin →
Settings → Federation. It has an emergency global stop and a feature-scoped
mode (disabled, allowlist, blocklist, or open), minimum trust
(tofu or verified), and per-domain inbound/outbound action (inherit,
allow, or block) with an optional trust override. Fresh databases start in
allowlist. disabled hides discovery/capability advertisement as well as
denying both directions. Saved rules survive mode changes, and open
intentionally ignores their admission actions; trust requirements still apply.
Admission policy is applied before outbound discovery/queuing and inbound origin discovery. Admitted peers must still pass discovery/history signatures, pinned-identity trust, SSRF, request/response signatures, replay, body, protocol, and rate-limit checks. First contact creates a TOFU pin only after cryptographic verification. The admin UI shows full fingerprints for out-of- band verification, discovery failures, rotations, and quarantine; break-glass re-pin requires the old and new full fingerprints plus the exact domain and is audited. A reverse-proxy IP rule is not an equivalent domain-identity policy.
The same responsive panel shows per-peer Chat delivery and Drive share counts, quarantined/failed filters, authenticated discovery timestamps, and the exact preserved signed identity documents behind a pin or quarantine. “Retry visible” re-resolves up to 100 filtered peers without treating one failure as a batch failure. The federation-only audit feed can be filtered to one domain and exported as spreadsheet-safe CSV; exported evidence contains public identity material and operational errors, never the server signing seed or plaintext Drive share capabilities.
After changing these values, rebuild the backend:
docker compose up -d --build backendChat account manifests and device limit
Section titled “Chat account manifests and device limit”V1 needs no transparency signing key, witness process, auditor service or
checkpoint monitor. Remove CHAT_TRANSPARENCY_SIGNING_KEY from deployment
configuration. Every account signs its own complete manifest locally; the
server verifies and distributes the current head and append-only history but
cannot mark a peer verified.
CHAT_MAX_ACTIVE_DEVICES defaults to 10 and accepts values from 1 through 10.
A lower value is a local resource/product policy. Ten is the V1 protocol hard
cap and cannot be raised by configuration:
CHAT_MAX_ACTIVE_DEVICES=10CHAT_MEDIA_MAX_PLAINTEXT_BYTES=2147483648Each browser profile, private window, or profile whose site storage was cleared is a separate Chat installation. Users can rename an installation from Messages to an account-private label such as “Work laptop”. The immutable numeric ID from 1 through 10 remains visible as secondary protocol-routing metadata. Renaming does not rotate device keys, change manifest membership, replace Direct/MLS sessions, or migrate protected history.
CHAT_MEDIA_MAX_PLAINTEXT_BYTES is the per-attachment plaintext-class ceiling.
It defaults to the V1 hard cap of 2 GiB; an operator may lower it, but cannot
raise it without a future typed media-suite/protocol revision. This is an
individual-object admission limit. All Chat media is charged to the dedicated
Chat quota; it never consumes the Drive/general quota.
First contact shows a gray shield. Users who require independent identity authentication meet face to face and scan the conversation safety QR; an exact match produces a green shield on that installation. A rollback, equivocation or signed account-incarnation replacement produces a red quarantine and blocks new sends. A healthy server response cannot clear it. For a legitimate destructive account reset, the user scans the replacement QR; the client keeps the old signed incarnation history and atomically promotes the new one.
Back up account recovery material. Recovery with the original phrase restores the same master key and account authority. Administrative wipe is termination, not recovery: it creates a new authority and requires contacts to re-verify.
Continuous Chat history and retention
Section titled “Continuous Chat history and retention”After unified recovery setup, Chat automatically protects display history and
eligible media in an account-local E2EE backup. There is no backup-disable or
device-transfer fallback. A clean browser restores verified history from the
account homeserver, but creates fresh device, Direct, and MLS protocol state.
See chat-backup.md for the lifecycle and
chat-backup-security-threat-model.md
for the trust boundary.
The hourly job applies 30-day mailbox retention and 45-day ordinary delivery-media retention by default. Runtime admin settings can override both, and zero disables that policy. Delivery-media cleanup never deletes separately protected history media. Account deletion and administrator loss-recovery wipe purge all backup rows, object prefixes, staging/reconciliation state, and charged Chat bytes.
Contacts-only sealed sender
Section titled “Contacts-only sealed sender”Provision the trust root on a machine that is not the Kutup application server. The image contains an offline helper; copying that binary to the offline system does not require copying the server configuration or database:
kutup-sealed-sender-provision root-generate /secure/kutup-sealed-root.key
kutup-sealed-sender-provision server-issue \ --domain kutup.example.com \ --root-key /secure/kutup-sealed-root.key \ --online-key /secure/kutup-sealed-online.key \ --certificate-id 1001 \ --activates-at <unix-seconds> \ --expires-at <unix-seconds> > sealed-policy.jsonBoth secret files are created once with mode 0600; the helper refuses to
overwrite them or read an overly permissive root file. Keep the root offline.
Install the canonical policy JSON as CHAT_SEALED_SENDER_POLICY and the exact
contents of kutup-sealed-online.key as
CHAT_SEALED_SENDER_ONLINE_PRIVATE_KEY. The server validates the root chain,
certificate window, online public/private match, suite, and domain at startup.
It advertises sealed sender only after the signed service policy is durable:
docker compose run --rm backend feature-policy rotate sealed-senderThe first deployment bootstraps sequence 1 automatically; the explicit command is required whenever persisted policy and configured policy differ. For root rotation, first publish both roots, activate a new root-signed online certificate, wait at least 24 hours plus the configured clock skew, then remove the old root in another policy sequence. Never delete an active old root in the same policy that introduces its replacement.
Sealed delivery is contacts-only. The 16-byte delivery capability is derived from the recipient profile key and only its SHA-256 verifier is stored. Blocking publishes a new profile key/verifier before redistributing that key to remaining contacts. Anonymous prekey and send routes accept neither cookies nor bearer tokens; destination mailboxes and federation transactions contain no sender account/device. First-contact requests, Note to Self, and linked-device sync remain identified, and an established sealed conversation never silently falls back to identified delivery.
Storage and Backups
Section titled “Storage and Backups”The complete recovery set spans these locations in the checked-in topology:
| Data | Location |
|---|---|
| PostgreSQL database | postgres_data (Docker named volume) |
| SeaweedFS master metadata | ./data/seaweedfs-master |
| SeaweedFS file chunks | ./data/seaweedfs-volume |
| SeaweedFS filer/S3 namespace metadata | /filerldb2 inside the seaweedfs-filer container unless you configure a durable filer store |
PostgreSQL contains the object references and encrypted key envelopes while SeaweedFS contains the corresponding ciphertext. Back them up as one recovery set. The simplest consistent operator procedure is a short maintenance window:
# Stop new application writes, then dump PostgreSQL.docker compose stop nginx backendmkdir -p backups/currentdocker compose exec postgres pg_dump -U "${POSTGRES_USER:-kutup}" "${POSTGRES_DB:-kutup}" | gzip > backups/current/postgres.sql.gz
# Quiesce SeaweedFS, copy its container-local filer metadata, then archive the# bind-mounted master and volume state.docker compose stop seaweedfs-s3 seaweedfs-filer seaweedfs-volume seaweedfs-masterdocker compose cp seaweedfs-filer:/filerldb2 backups/current/filerldb2tar -czf backups/current/seaweedfs-data.tar.gz data/
# Resume and wait for the complete stack.docker compose up -d --waitStore the PostgreSQL dump, filer metadata, and master/volume archive together
off-site and test restoration on an isolated host. They are one logical
recovery point; restoring only the volume chunks does not restore the S3
namespace. When using a mounted or external filer store, replace the
docker compose cp step with that store’s consistent backup procedure.
SeaweedFS object payloads are ciphertext, but database and object metadata are
still operationally sensitive. Protected content remains undecryptable without
the users’ account keys/recovery material; encryption is not a substitute for
durable operator backups.
Updating
Section titled “Updating”git pulldocker compose up -d --build --waitDatabase migrations run automatically on backend startup.
Running Behind an Existing Reverse Proxy
Section titled “Running Behind an Existing Reverse Proxy”If you already have Nginx or Caddy on the host, bind the bundled TLS proxy only to loopback. The default development ports can be made explicit as:
nginx: ports: - "127.0.0.1:38080:80" - "127.0.0.1:38443:443"Then proxy from the public edge to the bundled HTTPS listener. Prefer trusting
the private upstream certificate; proxy_ssl_verify off is acceptable only for
this loopback hop:
server { listen 443 ssl; server_name kutup.example.com;
ssl_certificate /etc/letsencrypt/live/kutup.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/kutup.example.com/privkey.pem;
location / { proxy_pass https://127.0.0.1:38443; proxy_ssl_verify off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # Required for large file uploads: client_max_body_size 0; proxy_request_buffering off; }}For Caddy:
kutup.example.com { reverse_proxy https://127.0.0.1:38443 { transport http { tls_insecure_skip_verify } }}Browser WASM cache policy
Section titled “Browser WASM cache policy”Kutup’s generated /chat-wasm/ and /crypto-wasm/ JavaScript glue and WASM
binaries use stable filenames and form one deployment unit with the web bundle
and API server. They must be revalidated and must never receive an immutable
cache policy from an outer reverse proxy or CDN. The bundled frontend sends
Cache-Control: no-cache, must-revalidate for both paths. Preserve that header
when adding a cache layer. Normal Vite /assets/ filenames are content-hashed
and may remain immutable.
Serving stale generated WASM with a newer JavaScript bundle can produce a fail-closed Chat or Drive initialization error because the Rust and HTTP DTOs no longer agree. Deploy the frontend, its generated WASM directories, and the backend from the same release.
Security Hardening
Section titled “Security Hardening”- Change all defaults in
.envbefore first start. The defaults are intentionally weak placeholders. - Firewall: Only expose ports 80 and 443. All other services (PostgreSQL, SeaweedFS) must not be reachable from the internet.
- JWT_SECRET: Use
openssl rand -hex 64. A weak secret allows forging authentication tokens. - ADMIN_ACCOUNT: Keep this set — it defines the protected break-glass admin (never demotable/deletable). Rotate its password after first login, but don’t remove the variable, or the break-glass protection lapses.
- Quotas: Set both Drive/general and dedicated Chat defaults in the admin dashboard to prevent abuse.
- Updates: Keep Docker images and the application updated.
SeaweedFS Bucket Versioning (required for collaborative editing)
Section titled “SeaweedFS Bucket Versioning (required for collaborative editing)”The collaborative-edit feature uses S3 object versioning to store file snapshots. The seaweedfs-init Compose service enables versioning and applies a lifecycle policy automatically on stack startup.
The compose stack has been updated to:
- Mount
seaweedfs-init.shandlifecycle.jsoninto the init container. - The script waits for SeaweedFS S3, creates the bucket (idempotent), enables versioning, applies the lifecycle.
Lifecycle defaults: 30-day or 50-version retention for noncurrent versions, whichever yields more. Named (keep_forever=true) versions are kept indefinitely (the kutup backend’s cleanup job filters them out — they don’t rely on the SeaweedFS lifecycle alone).
To customize retention, edit lifecycle.json and re-run the init container:
docker compose run --rm seaweedfs-initIf you migrate an existing pre-collab-edit deployment, run seaweedfs-init.sh once after upgrading. The script is idempotent.