> For the complete documentation index, see [llms.txt](https://docs.gotempest.app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.gotempest.app/deployment/self-host-tempest-server-docker-compose.md).

# Deploy the Tempest Server with Docker Compose

A complete, tested Docker Compose deployment of the open-source Tempest server image — Postgres, Redis, CouchDB, S3 storage, and the sign-in flow a real Tempest client uses.

[Tempest Server](/deployment/self-hosted-tempest-server.md) ships as a Docker image. The open-source core is published on Docker Hub as `tempestterm/tempest-web-services-oss`, and this page is the deployment that goes with it: one `docker-compose.yml`, one script that generates every secret, and a checklist that proves the install works before you point a client at it.

Everything below was run end to end on a plain Ubuntu 24.04 box with Docker 29. Nothing here is a sketch.

This is the **open-source** build. For what it does and does not include — shared vaults, two-factor authentication, relays, the AI assistant, the full administration console — see [OSS vs Enterprise Edition](/deployment/oss-vs-enterprise-edition.md).

## What the stack is made of

| Service    | Image                                  | Why it is there                                                         |
| ---------- | -------------------------------------- | ----------------------------------------------------------------------- |
| `web`      | `tempestterm/tempest-web-services-oss` | The Rails app: sign-in, Console, OAuth/OIDC, the gateway API            |
| `worker`   | same image                             | Sidekiq — scheduled snippet runs, mail, push relay                      |
| `postgres` | `postgres:17-alpine`                   | Accounts, teams, devices, OAuth grants                                  |
| `redis`    | `redis:7-alpine`                       | Sidekiq queues, Action Cable, cache                                     |
| `couchdb`  | `couchdb:3.5.2`                        | The encrypted vaults themselves — clients replicate against it directly |
| `rustfs`   | `rustfs/rustfs`                        | S3-compatible object storage for Active Storage (avatars, uploads)      |

One thing about the shape of it is worth knowing before you start.

**CouchDB is not behind the app.** Clients replicate straight against it, so its URL has to be reachable from every device, not just from the `web` container. Plan for two published endpoints, not one.

## Where configuration comes from

Tempest reads its configuration from three places. Most values accept more than one, so you can put a secret wherever it belongs in your setup rather than wherever the app happens to insist.

### 1. Mounted encrypted credentials

`config/credentials/production.yml.enc`, unlocked at boot by `RAILS_MASTER_KEY`. Mount the file, pass the key, and Rails decrypts it in memory — the plaintext never touches disk.

| Credential                                  | What it is                                                                                                                                   |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `jwt_privkey`                               | **Required.** Generated at install time in step 2, and paired with CouchDB there. No environment equivalent — it must come from credentials. |
| `oidc.signing_key`                          | RSA key for ID Tokens. Also settable as `OIDC_SIGNING_KEY`.                                                                                  |
| `sign_in_with_apple_pem`                    | Apple sign-in private key (Enterprise Edition)                                                                                               |
| `google_oauth.client_id` / `.client_secret` | Google sign-in (Enterprise Edition)                                                                                                          |
| `github_oauth.client_id` / `.client_secret` | GitHub sign-in (Enterprise Edition)                                                                                                          |
| `push.apns.*`, `push.fcm.*`                 | APNs / FCM credentials (Enterprise Edition)                                                                                                  |

Back up `RAILS_MASTER_KEY` and the encrypted file together. Without the key the file is unreadable, and without `jwt_privkey` the instance loses access to every vault database it has.

### 2. Environment variables

Some values have to arrive this way, because they are what the app needs *in order to* reach a database at all:

| Variable                                                                                                         | Notes                                                  |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `DATABASE_URL`                                                                                                   | Postgres connection URL                                |
| `REDIS_URL`                                                                                                      | Sidekiq, Action Cable and the cache                    |
| `SECRET_KEY_BASE`                                                                                                | Rails message signing                                  |
| `RAILS_MASTER_KEY`                                                                                               | Unlocks the credentials file above                     |
| `TEMPEST_EXTERNAL_URL`                                                                                           | The canonical browser-facing origin. Environment only. |
| `TEMPEST_FORCE_HTTPS`                                                                                            | HTTP → HTTPS redirect, off by default                  |
| `OIDC_SIGNING_KEY`, `OIDC_ISSUER`                                                                                | Wins over `credentials.oidc.signing_key`               |
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_BUCKET`, `AWS_ENDPOINT`, `AWS_FORCE_PATH_STYLE` | Active Storage                                         |
| `PORT`, `WEB_CONCURRENCY`, `RAILS_MAX_THREADS`, `DB_POOL`, `SIDEKIQ_CONCURRENCY`, `RAILS_LOG_LEVEL`              | Process sizing and logging                             |
| `AVO_LICENSE_KEY`                                                                                                | Administration console (Enterprise Edition)            |

**And every backend setting from the next section is also an environment variable**, spelled by uppercasing the key and turning non-alphanumerics into `_`: `couchdb_host` → `COUCHDB_HOST`, `resend_api_key` → `RESEND_API_KEY`, `skip_registration_confirmation` → `SKIP_REGISTRATION_CONFIRMATION`.

### 3. The database

Everything in the `backend_settings` table is a `key` / `value_text` row, and can be set there instead of in the environment. The point of this channel is that it takes effect **without a redeploy** — the value is resolved per request, so you can flip a setting on a running instance.

| Setting                                                                     | ENV spelling                                 | What it does                                         |
| --------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------- |
| `couchdb_host`                                                              | `COUCHDB_HOST`                               | **Required.** The CouchDB URL handed to clients      |
| `admin_emails`                                                              | `ADMIN_EMAILS`                               | Bootstrap instance admins, comma- or space-separated |
| `resend_api_key`                                                            | `RESEND_API_KEY`                             | Outbound mail                                        |
| `skip_registration_confirmation`                                            | `SKIP_REGISTRATION_CONFIRMATION`             | Let sign-ups in without confirming their address     |
| `skip_invitation_confirmation`                                              | `SKIP_INVITATION_CONFIRMATION`               | Same, for invitees (EE)                              |
| `download_bucket_url`                                                       | `DOWNLOAD_BUCKET_URL`                        | Serve `/download` from your own bucket               |
| `min_client_version`                                                        | `MIN_CLIENT_VERSION`                         | Refuse clients older than this                       |
| `disable_personal_team`                                                     | `DISABLE_PERSONAL_TEAM`                      | Turn off personal vaults instance-wide               |
| `default_shared_team_id`                                                    | `DEFAULT_SHARED_TEAM_ID`                     | Auto-join new accounts to a team (EE)                |
| `couchdb_conflict_cleanup`                                                  | `COUCHDB_CONFLICT_CLEANUP`                   | Background conflict sweep                            |
| `argon2_memory_kib`, `argon2_iterations`, `argon2_parallelism`              | `ARGON2_MEMORY_KIB`, …                       | Master-password KDF cost                             |
| `apns_sandbox`                                                              | `APNS_SANDBOX`                               | APNs environment (EE)                                |
| `turnstile_site_key`, `turnstile_secret_key`                                | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` | Bot challenge on the sign-in forms (EE)              |
| `openai_api_key`, `openai_base_url`, `ai_codegen_model`, `ai_max_tokens`, … | `OPENAI_API_KEY`, …                          | AI assistant (EE)                                    |
| `license_status_api`                                                        | `LICENSE_STATUS_API`                         | License check endpoint (EE)                          |

Enterprise Edition exposes these in its administration console. On an open-source build, write them from a console:

```sh
docker compose run --rm --entrypoint launcher web bin/rails runner \
  'BackendSetting.find_or_initialize_by(key: "resend_api_key").update!(value_text: "re_…")'
```

### Precedence

For anything that `BackendSettings` resolves, the order is fixed:

1. The environment variable, **if it is set and not empty**
2. The `backend_settings` row
3. The built-in default — and where there is none, boot fails loudly with `Backend setting missing: <key> (ENV <KEY> or database)` rather than running on a guess

An empty environment variable is treated as absent, so `RESEND_API_KEY=` falls through to the database rather than blanking it. The flip side is the one that bites: an exported variable outranks the database row *and* your `.env` file, so a stale `export` in your shell will quietly win over the value you just edited.

## 1. Lay out the directory

```sh
mkdir -p ~/tempest-oss/{secrets,couchdb,tools}
cd ~/tempest-oss
docker pull tempestterm/tempest-web-services-oss:latest
```

## 2. Generate the credentials and the CouchDB keypair

Tempest and CouchDB are paired by one EC keypair created at install time: the private half goes into the encrypted credentials, the public half into CouchDB's config in step 3. Generate it *inside* the image, so the credentials file is written by the same Rails version that will read it.

Save this as `inner-gen.sh`:

```sh
#!/usr/bin/env bash
set -euo pipefail

# Runs INSIDE the image. Produces, into /secrets:
#   production.key        -> RAILS_MASTER_KEY
#   credentials.yml.enc   -> encrypted credentials holding jwt_privkey
#   couchdb_jwt_pub.pem   -> the matching public key, for CouchDB

openssl ecparam -name prime256v1 -genkey -noout -out /tmp/jwt.key
openssl ec -in /tmp/jwt.key -pubout -out /tmp/jwt.pub

cat > /tmp/editor <<'EOS'
#!/usr/bin/env bash
{ echo "jwt_privkey: |"; sed 's/^/  /' /tmp/jwt.key; } > "$1"
EOS
chmod +x /tmp/editor

rm -rf /workspace/config/credentials
EDITOR=/tmp/editor bin/rails credentials:edit --environment production

cp /workspace/config/credentials/production.key      /secrets/production.key
cp /workspace/config/credentials/production.yml.enc  /secrets/credentials.yml.enc
cp /tmp/jwt.pub                                      /secrets/couchdb_jwt_pub.pem
```

Run it. `launcher` is the Cloud Native Buildpacks entrypoint — it is how you run any command other than the image's default process:

```sh
docker run --rm -u 0 \
  -v "$PWD/secrets:/secrets" \
  -v "$PWD/inner-gen.sh:/tmp/gen.sh:ro" \
  --entrypoint launcher tempestterm/tempest-web-services-oss:latest \
  bash /tmp/gen.sh

# the files come out owned by root; hand them back
docker run --rm -u 0 -v "$PWD/secrets:/s" alpine sh -c 'chown -R 1000:1000 /s; chmod 640 /s/*'
```

`production.key` is your `RAILS_MASTER_KEY`. **Back it up.** Lose it and every vault key escrow in `credentials.yml.enc` is gone for good.

## 3. Generate `couchdb/local.ini` and `.env`

Save this as `gen-config.sh` and set `HOST_IP` to the address clients will use.

```sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"

HOST_IP="${HOST_IP:-tempest.example.com}"

# --- CouchDB: trust JWTs signed by the app's EC key --------------------------
# CouchDB's ini parser wants the PEM on one line with literal \n escapes.
PUB_ESCAPED="$(awk '{printf "%s\\n", $0}' secrets/couchdb_jwt_pub.pem)"
rm -f couchdb/local.ini
cat > couchdb/local.ini <<EOF
[couchdb]
; Creates _users / _replicator / _global_changes on first boot. Without it a
; fresh single-node CouchDB crash-loops on {database_does_not_exist,<<"_users">>}.
single_node = true

[chttpd]
authentication_handlers = {chttpd_auth, jwt_authentication_handler}, {chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}

[jwt_auth]
required_claims = exp
; Leave roles_claim_path unset — CouchDB's default is the correct one here.

[jwt_keys]
ec:_default = ${PUB_ESCAPED}

[cors]
credentials = true
origins = *
headers = accept, authorization, content-type, origin, referer, x-csrf-token
methods = GET, PUT, POST, HEAD, DELETE

[chttpd_auth]
require_valid_user = true
EOF

# The CouchDB entrypoint starts with
#   find /opt/couchdb ! \( -user couchdb -group couchdb \) -exec chown -f … +
#   find /opt/couchdb/etc -type f ! -perm 0644 -exec chmod -f 0644 … +
# Both fail on a read-only bind mount, `set -e` kills the container, and it
# exits 1 with a COMPLETELY EMPTY log. Hand CouchDB a file it will skip over:
# owned by uid/gid 5984 and already mode 0644.
chmod 644 couchdb/local.ini
docker run --rm -u 0 -v "$PWD/couchdb:/c" alpine chown 5984:5984 /c/local.ini

# --- .env --------------------------------------------------------------------
if [ ! -f .env ]; then
  OIDC_KEY="$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}')"
  cat > .env <<EOF
TEMPEST_IMAGE=tempestterm/tempest-web-services-oss:latest

TEMPEST_EXTERNAL_URL=https://${HOST_IP}
COUCHDB_HOST=https://couchdb.${HOST_IP}
ADMIN_EMAILS=you@example.com

RAILS_MASTER_KEY=$(cat secrets/production.key)
SECRET_KEY_BASE=$(openssl rand -hex 64)

POSTGRES_USER=tempest
POSTGRES_PASSWORD=$(openssl rand -hex 24)
POSTGRES_DB=tempest_web_services

COUCHDB_USER=admin
COUCHDB_PASSWORD=$(openssl rand -hex 16)

S3_ACCESS_KEY=tempest
S3_SECRET_KEY=$(openssl rand -hex 24)
S3_BUCKET=tempest

OIDC_SIGNING_KEY="${OIDC_KEY}"
EOF
  chmod 600 .env
fi
```

Every value is generated per install. Nothing in this file should ever be copied from someone else's deployment.

Three of those deserve a note:

* `TEMPEST_EXTERNAL_URL` — the canonical browser-facing origin. Scheme required, no path. It drives absolute URLs, the OIDC issuer, WebAuthn, Action Cable origins and the `Secure` cookie flag. Add `TEMPEST_FORCE_HTTPS=true` only when it is `https://` **and** your proxy sends an accurate `X-Forwarded-Proto`.
* `COUCHDB_HOST` — the URL *clients* will use, so an internal `http://couchdb:5984` will not do.
* `ADMIN_EMAILS` — the bootstrap instance admin. A fresh instance has nobody who can grant admin to anyone, so this is how an installation names its own. Comma-separated.

Both `COUCHDB_HOST` and `ADMIN_EMAILS` are backend settings, so you can move them into the database later and change them without a restart.

Then run it:

```sh
chmod +x gen-config.sh && ./gen-config.sh
```

## 4. `docker-compose.yml`

```yaml
name: tempest-oss

x-app: &app
  image: ${TEMPEST_IMAGE}
  restart: unless-stopped
  volumes:
    - ./secrets/credentials.yml.enc:/workspace/config/credentials/production.yml.enc:ro
    # One-off maintenance scripts for `bin/rails runner /tools/<name>.rb`.
    - ./tools:/tools:ro
  environment:
    RAILS_ENV: production
    RAILS_LOG_TO_STDOUT: "true"
    RAILS_SERVE_STATIC_FILES: "true"
    RAILS_MASTER_KEY: ${RAILS_MASTER_KEY}
    SECRET_KEY_BASE: ${SECRET_KEY_BASE}

    TEMPEST_EXTERNAL_URL: ${TEMPEST_EXTERNAL_URL}
    ADMIN_EMAILS: ${ADMIN_EMAILS}

    COUCHDB_HOST: ${COUCHDB_HOST}
    RESEND_API_KEY: ${RESEND_API_KEY:-}
    SKIP_REGISTRATION_CONFIRMATION: ${SKIP_REGISTRATION_CONFIRMATION:-false}

    DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
    REDIS_URL: redis://redis:6379/1

    OIDC_SIGNING_KEY: ${OIDC_SIGNING_KEY}

    AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY}
    AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
    AWS_REGION: us-east-1
    AWS_BUCKET: ${S3_BUCKET}
    AWS_ENDPOINT: http://rustfs:9000
    AWS_FORCE_PATH_STYLE: "true"

    PORT: "5000"
    WEB_CONCURRENCY: "2"
    RAILS_MAX_THREADS: "5"
    SIDEKIQ_CONCURRENCY: "5"
  depends_on:
    postgres: { condition: service_healthy }
    redis:    { condition: service_healthy }

services:
  postgres:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 20

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server", "--save", "60", "1"]
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 20

  couchdb:
    image: couchdb:3.5.2
    restart: unless-stopped
    environment:
      COUCHDB_USER: ${COUCHDB_USER}
      COUCHDB_PASSWORD: ${COUCHDB_PASSWORD}
    volumes:
      - couchdb-data:/opt/couchdb/data
      - ./couchdb/local.ini:/opt/couchdb/etc/local.d/local.ini:ro
    ports:
      - "5984:5984"

  rustfs:
    image: rustfs/rustfs:latest
    restart: unless-stopped
    environment:
      RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY}
      RUSTFS_SECRET_KEY: ${S3_SECRET_KEY}
      RUSTFS_VOLUMES: /data
    volumes:
      - rustfs-data:/data
      - rustfs-logs:/logs
    ports:
      - "9000:9000"
      - "9001:9001"

  web:
    <<: *app
    entrypoint: ["/cnb/process/web"]
    ports:
      - "51415:5000"
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/up > /dev/null"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 40s

  worker:
    <<: *app
    entrypoint: ["/cnb/process/worker"]

volumes:
  postgres-data:
  redis-data:
  couchdb-data:
  rustfs-data:
  rustfs-logs:
```

Two details that are not cosmetic:

* **`entrypoint:` selects the process type.** The image is built by Cloud Native Buildpacks, so `/cnb/process/web` and `/cnb/process/worker` come from the `Procfile`. A `command:` override would be passed *to* the web process instead of replacing it.
* **Use named volumes, not `./data` bind mounts.** CouchDB and RustFS run as non-root and cannot write into a host directory your user owns; RustFS fails with a bare `[FATAL] Server runtime failed: Io error: Permission denied (os error 13)`.

## 5. Start it and migrate

```sh
docker compose up -d
docker compose run --rm --entrypoint launcher web bin/rails db:migrate
```

Use `db:migrate`, **not** `db:prepare`. On an empty database `db:prepare` also runs the seed step, which expects Enterprise Edition to be present and aborts on an open-source build. `db:migrate` does exactly what a fresh install needs and finishes cleanly.

## 6. Register the OAuth clients

**This step is required, and it is the one people miss.** A fresh instance has no OAuth clients registered, and every Tempest client has its `client_id` compiled in. Until you register them, sign-in fails before it begins.

Save as `tools/oauth_apps.rb`:

```ruby
# A fresh instance has no OAuth clients. The client ids are compiled into the
# published desktop, mobile, web and CLI builds, so they are not yours to
# choose — read the registry the image already carries rather than inventing
# your own, and every client will sign in against this instance unmodified.
source = File.read(Rails.root.join('db/seeds.rb'))
literal = source[/oauth_apps\s*=\s*(\[.*?^  \])/m, 1] or
  abort('could not find the OAuth client registry in this image')

eval(literal).each do |attrs| # rubocop:disable Security/Eval
  app = Doorkeeper::Application.find_or_initialize_by(uid: attrs[:uid])
  app.assign_attributes(attrs)
  app.save!
  puts "[oauth] #{attrs[:name]}"
end
```

It is idempotent, and it prints one line per client:

```
[oauth] Tempest Mobile
[oauth] Tempest Desktop
[oauth] Tempest Web
[oauth] Tempest CLI
```

```sh
docker compose run --rm --entrypoint launcher web bin/rails runner /tools/oauth_apps.rb
```

## 7. Create the storage bucket

Active Storage will not create its own bucket. Use the app's own S3 client rather than a second CLI — same credentials, same endpoint, nothing else to install. Save as `tools/create_bucket.rb`:

```ruby
bucket = ENV.fetch('AWS_BUCKET')
s3 = ActiveStorage::Blob.service.client.client
begin
  s3.create_bucket(bucket: bucket)
  puts "[bucket] created #{bucket}"
rescue Aws::S3::Errors::BucketAlreadyOwnedByYou, Aws::S3::Errors::BucketAlreadyExists
  puts "[bucket] already exists: #{bucket}"
end
```

```sh
docker compose run --rm --entrypoint launcher web bin/rails runner /tools/create_bucket.rb
```

## 8. Verify before you point a client at it

Six checks. Each one fails loudly on its own, and together they cover every moving part.

```sh
BASE=http://127.0.0.1:51415

# 1 — the app is up and knows its own origin
curl -s -o /dev/null -w '%{http_code}\n' $BASE/up
curl -s $BASE/.well-known/openid-configuration | head -c 80

# 2 — CouchDB is up
curl -s http://127.0.0.1:5984/

# 3 — no vault databases yet
curl -s "http://$COUCHDB_USER:$COUCHDB_PASSWORD@127.0.0.1:5984/_all_dbs"
```

Then create an account at `$BASE/users/sign_up`, sign in, and confirm `/console` returns 200. If you set `ADMIN_EMAILS` to that address, the background-job dashboard at `/admin/sidekiq` returns 200 too.

Finally, walk the real client flow: OAuth 2.0 authorization code + PKCE, then call `/info` with the token. A working instance answers:

```json
{
  "database_url": "https://couchdb.tempest.example.com/tempest_u1",
  "personal_vault": {
    "url": "https://couchdb.tempest.example.com/tempest_u1",
    "team_uuid": "…", "encryption_version": null, "enabled": true
  },
  "additional_vaults": [],
  "current_team": { "name": "Personal", "personal": true, "my_role": "admin" }
}
```

`additional_vaults` is always empty in an open-source build — shared vaults are an [Enterprise Edition](/deployment/oss-vs-enterprise-edition.md) feature.

Re-run check 3 afterwards: `_all_dbs` now lists a vault database that was not there before. That is the end-to-end proof — the app reached CouchDB, was accepted, and provisioned storage for the account. If it is still empty, the pairing from steps 2 and 3 is wrong; see the troubleshooting table.

## 9. Put TLS in front of it

Plain HTTP is fine on a trusted LAN and nowhere else: Apple sign-in and WebAuthn passkeys both require HTTPS on a non-localhost origin. Terminate TLS at a reverse proxy — Caddy, nginx, or Nginx Proxy Manager — and give it two names: one for the app on `51415`, one for CouchDB on `5984`. Both must be publicly resolvable, because clients talk to CouchDB directly.

The proxy has to preserve the original host and protocol:

```nginx
proxy_set_header Host              $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port  $server_port;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
```

Keep `TEMPEST_EXTERNAL_URL=https://…` even though the proxy-to-Rails hop is plain HTTP. Set `TEMPEST_FORCE_HTTPS=true` only once the header above is genuinely arriving — otherwise Rails redirect-loops.

## 10. Point a client at it

On the Tempest welcome screen, choose **Self-hosted server**, enter your `TEMPEST_EXTERNAL_URL`, and sign in. From there it is the same product: [Web Mode](/deployment/self-hosted-tempest-server.md) in the browser, encrypted vault sync, [scheduled snippets](/productivity/snippets-scheduled-runs.md), [push notifications](/productivity/tempest-push-notifications.md).

## Day-two operations

```sh
# upgrade
docker compose pull web worker
docker compose up -d web worker
docker compose run --rm --entrypoint launcher web bin/rails db:migrate

# logs
docker compose logs -f web worker

# grant or revoke an instance admin on the account itself
docker compose run --rm --entrypoint launcher web bin/rails 'admin:grant[ops@example.com]'
docker compose run --rm --entrypoint launcher web bin/rails admin:list

# back this up, in this order of importance
#   secrets/production.key + secrets/credentials.yml.enc   (irreplaceable)
#   the couchdb-data volume                                (the vaults)
#   the postgres-data volume                               (accounts, grants)
```

Revoking an admin means revoking it in both places: an address left in `ADMIN_EMAILS` stays an admin no matter what the database column says.

## Troubleshooting

| Symptom                                                  | Cause                                                                                                                                                                  |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `couchdb` exits 1 with **no log output at all**          | `local.ini` is not owned by uid 5984 / not mode 0644. Its entrypoint's `chown`/`chmod` sweep fails on the read-only mount and `set -e` kills it before logging starts. |
| `{database_does_not_exist,<<"_users">>}` crash loop      | `single_node = true` missing from `[couchdb]`.                                                                                                                         |
| CouchDB returns `missing json key: _couchdb`             | `roles_claim_path` is set in `[jwt_auth]`. Remove it.                                                                                                                  |
| CouchDB returns 401 to the app                           | `local.ini` does not carry the public key generated in step 2. Re-run `gen-config.sh` and restart CouchDB.                                                             |
| `rustfs` restarts with `Permission denied (os error 13)` | You bind-mounted a host directory instead of using a named volume.                                                                                                     |
| `db:prepare` aborts on a missing constant                | Use `db:migrate` instead.                                                                                                                                              |
| Client sign-in fails immediately                         | The OAuth applications from step 6 were never created.                                                                                                                 |
| Uploads 500 with `NoSuchBucket`                          | Step 7 was skipped.                                                                                                                                                    |
| `.env` edits do not take effect                          | A variable of the same name is exported in your shell — a real environment variable outranks `.env` in Compose. `unset` it.                                            |

## See also

* [Self-Hosted Tempest Server & Web Mode](/deployment/self-hosted-tempest-server.md) — what the server does, and why you would run one
* [End-to-End Encryption](/account-and-privacy/end-to-end-encryption.md) — unchanged whether you self-host or not
* [Where Tempest Stores Your Credentials](/account-and-privacy/where-tempest-stores-credentials.md)
