> 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/self-hosting-and-web-mode/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](/self-hosting-and-web-mode/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.

These steps target an Ubuntu 24.04 host with Docker installed. After configuring the stack, run the checks in step 8 before connecting a client.

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](/self-hosting-and-web-mode/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                                                                                                                                                                                                      |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secret_key_base`                           | **Required.** Generated and persisted in step 2 for Rails signing and database key wrapping. Production reads it only from credentials, ignoring the `SECRET_KEY_BASE` environment variable and image defaults. |
| `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 decryption key the file is unreadable; losing `jwt_privkey` prevents access to existing vault databases, and losing `secret_key_base` prevents unwrapping existing database keys. **Before upgrading an existing deployment, move its effective Rails keybase unchanged into credentials; do not regenerate it.**

`SECRET_KEY_BASE_DUMMY` is for image builds only. Never set it on `web`, `worker` or production maintenance commands: its presence skips the production credentials keybase check.

### 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                    |
| `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`, `smtp_address` → `SMTP_ADDRESS`, `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                           |
| `mail_from`                                                                 | `MAIL_FROM`                                  | Sender for app and Devise mail; default `Tempest <account@gotempest.app>`      |
| `smtp_address`                                                              | `SMTP_ADDRESS`                               | SMTP server hostname; required when sending mail in production                 |
| `smtp_port`                                                                 | `SMTP_PORT`                                  | Default `587`, or `465` when `SMTP_TLS=true`                                   |
| `smtp_username`, `smtp_password`                                            | `SMTP_USERNAME`, `SMTP_PASSWORD`             | SMTP credentials, if required by your provider                                 |
| `smtp_authentication`                                                       | `SMTP_AUTHENTICATION`                        | Provider's authentication method, such as `plain` or `login`; unset by default |
| `smtp_domain`                                                               | `SMTP_DOMAIN`                                | Optional HELO/EHLO domain                                                      |
| `smtp_tls`                                                                  | `SMTP_TLS`                                   | Implicit TLS; default `false`                                                  |
| `smtp_enable_starttls_auto`                                                 | `SMTP_ENABLE_STARTTLS_AUTO`                  | STARTTLS; default `true`, automatically disabled when `SMTP_TLS=true`          |
| `smtp_openssl_verify_mode`                                                  | `SMTP_OPENSSL_VERIFY_MODE`                   | Optional TLS certificate verification mode                                     |
| `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: "smtp_address").update!(value_text: "smtp.example.com")'
```

### 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 `SMTP_ADDRESS=` 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.

### Outbound mail (SMTP)

Resend API delivery has been replaced by generic SMTP. Remove `RESEND_API_KEY` from an existing deployment and configure your mail provider's SMTP endpoint and credentials. Use a sender address your provider authorizes. For a typical authenticated STARTTLS connection, add this to `.env`:

```dotenv
MAIL_FROM="Tempest <no-reply@example.com>"
SMTP_ADDRESS=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD='your-smtp-password'
SMTP_AUTHENTICATION=login
SMTP_TLS=false
SMTP_ENABLE_STARTTLS_AUTO=true
# Optional HELO/EHLO domain:
# SMTP_DOMAIN=example.com
```

For implicit TLS, set `SMTP_TLS=true` and `SMTP_PORT=465`; STARTTLS is then disabled automatically. Match the port and authentication method to your provider. Leave username, password and authentication empty if your SMTP relay does not require them.

The Compose configuration below passes these settings to both `web` and `worker`, since background jobs send mail too. After editing `.env`, recreate both containers with `docker compose up -d web worker`. Database settings are read per message and can change without a restart, provided no non-empty environment variable overrides them.

`SMTP_ADDRESS` is checked when a message is built, not at boot: the app can start without it, but production mail fails with `Backend setting smtp_address is missing`. Configure SMTP before registering accounts so confirmation and password-reset emails can be delivered. `SKIP_REGISTRATION_CONFIRMATION=true` bypasses sign-up confirmation only; other email still requires SMTP.

## 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 secret_key_base and jwt_privkey
#   couchdb_jwt_pub.pem   -> the matching public key, for CouchDB

# Fresh installs only: never overwrite an existing instance's keys.
if [ -e /secrets/credentials.yml.enc ] || [ -e /secrets/production.key ]; then
  printf '%s\n' 'Credentials already exist; preserve the existing keys when upgrading.' >&2
  exit 1
fi

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
{
  printf 'secret_key_base: %s\n' "$(openssl rand -hex 64)"
  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.

## Vault history retention

If you want to retain earlier vault revisions, disable CouchDB automatic database compaction on every node. Tempest's History reads retained revision bodies; automatic or manual compaction can remove them. Add an empty `db_channels` under `[smoosh]` to your persisted `couchdb/local.ini`, restart CouchDB and verify the effective setting. Monitor disk growth and maintain database backups. See [Vault History — Self-hosting](/accounts-vaults-and-privacy/vault-history.md#self-hosting-disable-couchdb-automatic-database-compaction) for the configuration and limits. Tempest Cloud does not guarantee history retention.

## 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
; Keep old vault revision bodies for History. Monitor disk use.
[smoosh]
db_channels =

[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)

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}

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

    COUCHDB_HOST: ${COUCHDB_HOST}
    MAIL_FROM: ${MAIL_FROM:-}
    SMTP_ADDRESS: ${SMTP_ADDRESS:-}
    SMTP_PORT: ${SMTP_PORT:-}
    SMTP_USERNAME: ${SMTP_USERNAME:-}
    SMTP_PASSWORD: ${SMTP_PASSWORD:-}
    SMTP_AUTHENTICATION: ${SMTP_AUTHENTICATION:-}
    SMTP_DOMAIN: ${SMTP_DOMAIN:-}
    SMTP_TLS: ${SMTP_TLS:-}
    SMTP_ENABLE_STARTTLS_AUTO: ${SMTP_ENABLE_STARTTLS_AUTO:-}
    SMTP_OPENSSL_VERIFY_MODE: ${SMTP_OPENSSL_VERIFY_MODE:-}
    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`, verify the confirmation email arrives and follow its link (unless you enabled `SKIP_REGISTRATION_CONFIRMATION`), 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](/self-hosting-and-web-mode/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](/self-hosting-and-web-mode/self-hosted-tempest-server.md) in the browser, encrypted vault sync, [scheduled snippets](/ai-and-automation/snippets-scheduled-runs.md), [push notifications](/terminal-and-operations/tempest-push-notifications.md).

## Upgrade from 3.16.0 to 3.17.0

Earlier versions of this guide stored `SECRET_KEY_BASE` in `.env`. Production now reads `secret_key_base` only from encrypted credentials. **Migrate first, then update the image; do not rerun the initialization script in step 2.**

1. While the old version still works, back up the database, existing `.env`, `secrets/production.key` and `secrets/credentials.yml.enc`.
2. Confirm the old instance's effective Rails `secret_key_base`. For a deployment following the old guide without dummy enabled, this is the `SECRET_KEY_BASE` passed to the container. If the instance relied on an image default, recover and securely preserve that effective old value first; do not generate a replacement.
3. Using the existing decryption key, edit encrypted credentials and set the top-level `secret_key_base` to **exactly the same old value**, preserving `jwt_privkey` and all other credentials. Do not print secrets into public logs or chats.
4. Ensure `web` and `worker` mount the same updated `.enc` and continue using the original `RAILS_MASTER_KEY`. After migrating, remove `SECRET_KEY_BASE` from the Compose environment; do not set `SECRET_KEY_BASE_DUMMY` to bypass validation.
5. Update the image, run migrations and verify existing credentials and vault access. Without `secret_key_base`, the new version refuses to start instead of using a buildpack default.

Moving the unchanged key from ENV into `.enc` does not rotate database wrapping keys. Replacing keybase is a separate migration requiring the old key and rewrapping of data keys; do not combine it with this upgrade.

## 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.                                                                                                                                              |
| Mail fails with `Backend setting smtp_address is missing` | Set `SMTP_ADDRESS` and your provider's SMTP settings for both `web` and `worker`, or configure the equivalent database settings.                                       |
| 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](/self-hosting-and-web-mode/self-hosted-tempest-server.md) — what the server does, and why you would run one
* [End-to-End Encryption](/accounts-vaults-and-privacy/end-to-end-encryption.md) — unchanged whether you self-host or not
* [Where Tempest Stores Your Credentials](/accounts-vaults-and-privacy/where-tempest-stores-credentials.md)
