Self-host

Install Clane Vault on your own servers

One container from ghcr.io/clane-ai/clane-vault, your Postgres and your HTTPS proxy. This page follows the install guide that ships with the bundle.

Requirements

  • Docker with Compose v2, on Linux, macOS or Windows
  • Internet access to pull the public image ghcr.io/clane-ai/clane-vault (no registry login needed)
  • A Postgres 14 or later database, or the bundled Postgres that the installer can set up
  • An HTTPS reverse proxy (nginx, Caddy, Traefik or a cloud load balancer) with a certificate for your hostname

The container keeps no state of its own, so it needs no volume unless you write backups to a local folder. Managed Postgres works too, including Supabase, Azure and Amazon RDS.

The install bundle

The bundle is a small folder: docker-compose.yml, .env.example, install and update scripts for Linux, macOS and Windows, and the full install guide. It is the deploy/client folder on GitHub; download it with the repository. This is the compose file:

docker-compose.yml
name: clane-vault
services:
  clane-vault:
    image: ${CLANE_VAULT_IMAGE:-ghcr.io/clane-ai/clane-vault}:${CLANE_VAULT_VERSION:-1.6.2}
    env_file: .env
    ports: ["${HTTP_BIND:-127.0.0.1}:${HTTP_PORT:-8080}:3001"]
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
        required: false # ignored when you use your own Postgres (bundled-db profile off)

  # Optional bundled database. Only started when the bundled-db profile is enabled
  # (install.sh sets COMPOSE_PROFILES=bundled-db in .env, or run: docker compose --profile bundled-db up -d).
  # If you use your own Postgres, set DATABASE_URL in .env and do not enable this profile.
  postgres:
    profiles: ["bundled-db"]
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: vault
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
      POSTGRES_DB: vault
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U vault -d vault"]
      interval: 5s
      timeout: 5s
      retries: 20

volumes:
  pgdata:

Install

Run the installer from the bundle folder.

Linux or macOS
./install.sh
Windows (PowerShell)
.\install.ps1

The installer:

  1. creates .env with random secrets if it doesn't exist yet (it never overwrites one)
  2. asks whether to use the bundled Postgres, and for the public address people will open
  3. pulls the image, starts it and waits until /api/v1/health answers

To use your own Postgres, answer n and enter its connection string. The database user needs to be able to create tables, because migrations run automatically every time the container starts.

Then check .env, in particular these settings:

.env (excerpt)
CLANE_VAULT_VERSION=1.6.2
HTTP_BIND=127.0.0.1
HTTP_PORT=8080
DATABASE_URL=postgresql://user:password@host:5432/dbname
# The https address people open. No trailing slash.
PUBLIC_URL=https://vault.example.com
# Number of reverse proxies between the internet and the container.
TRUST_PROXY=1
COOKIE_SECURE=true

Keep a copy of .env in your secrets manager. SERVER_SECRET encrypts everyone's authenticator secrets. If it is lost or changed, nobody can sign in with their authenticator code until an admin resets their two-step sign-in. Never commit or share the file.

Create the first admin

docker compose exec clane-vault node dist/src/scripts/bootstrap.js --email [email protected]

This prints a one-time invite link that works for 48 hours. Open it, set a master password and set up an authenticator app. That account owns the main organisation and is the instance admin. The command refuses to run if an admin already exists; invite everyone else from the app.

To add or remove an instance admin later, or give an organisation a new owner:

docker compose exec clane-vault node dist/src/scripts/instance-admin.js --email [email protected] --grant   # or --revoke
docker compose exec clane-vault node dist/src/scripts/org-owner.js --email [email protected] [--org <slug>]

Put an HTTPS proxy in front

The container speaks plain HTTP on 127.0.0.1:8080. Put your HTTPS reverse proxy in front of it and forward X-Forwarded-For and X-Forwarded-Proto. Don't expose the plain HTTP port to the internet.

Set TRUST_PROXY to the number of proxies between the internet and the container: 1 for a single proxy, 2 for two in a row. Don't set it to true. Each proxy in the chain must append to X-Forwarded-For, not overwrite it. If the app sees a proxy's address instead of the user's, everyone shares one rate-limit bucket and a few failed sign-ins lock everybody out for a minute.

Imports upload batches of up to about 2 MB, so allow at least 5 MB per request on the proxy (for nginx, client_max_body_size 5m;). File attachments travel in 1 MiB chunks and need no extra setting.

The app sends Strict-Transport-Security only when it sees X-Forwarded-Proto: https through the trusted chain. Check it with:

curl -sI https://your-host/ | grep -i strict

Running under a path

The same image can run at the root of a host or under a path such as https://example.com/vault. The path is read at start-up, so there's nothing to rebuild.

  1. In .env, set PUBLIC_URL to the full address including the path, with no trailing slash. You can also set BASE_PATH, but it must then match the path in PUBLIC_URL or the server won't start. Path segments may use only A-Z a-z 0-9 . _ ~ -.
    PUBLIC_URL=https://example.com/vault
    # optional, must match the path above
    BASE_PATH=/vault
  2. Run docker compose up -d. The container now serves only under the path: the web app at /vault/ and the API at /vault/api/v1. Requests outside /vault get a 404.
    curl http://127.0.0.1:8080/vault/api/v1/health
  3. Point the reverse proxy at the container without stripping the prefix: the app expects to see /vault/…. For plain nginx:
    location /vault {
        proxy_pass http://127.0.0.1:8080;   # no URI part: the /vault prefix is passed through
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

Invite, recovery and one-time links, the single sign-on redirect address, the session cookie, the installable app and the browser extension all follow the path. Register https://example.com/vault/api/v1/auth/sso/callback with your identity provider. Several instances can share one host under different paths.

To move an existing install to a path, change PUBLIC_URL, restart, update the redirect address at the identity provider and reconnect browser extensions. Invites and one-time links already sent point at the old address and must be sent again.

Running in production

  • Run one replica. Rate limits and the clean-up of expired links run in the app's memory, so more replicas multiply the limits.
  • Keep the host clock on NTP. Authenticator codes fail if the clock drifts by more than about 30 seconds.
  • GET /api/v1/health returns {"ok":true,"db":true}. It also checks the database, so allow a few retries before restarting the container.
  • Pin CLANE_VAULT_VERSION rather than following the latest image.
  • Restrict network access to Postgres to the container host, and keep HTTP_BIND=127.0.0.1 unless the proxy runs on another host over a private network.

On managed Postgres, use a dedicated role and schema and a session-mode connection (port 5432). On Supabase, avoid the schema name vault, which Supabase Vault uses.

Managed Postgres connection string
DATABASE_URL=postgresql://clane_vault_app:…@host:5432/postgres?schema=clane_vault&sslmode=require&connection_limit=5

File attachments are stored in Postgres by default and are included in the database backup. To keep them in an S3-compatible bucket instead, set ATTACHMENT_STORAGE=s3 and the ATTACHMENT_S3_* settings, and back that bucket up yourself. On Supabase Free, set ATTACHMENT_INSTANCE_QUOTA_MB=200 or ATTACHMENTS_ENABLED=false.

Backups

The database holds vault items and files only as ciphertext, but losing it loses everything, so back it up. The app can do this itself on a schedule: it dumps its own schema with pg_dump, encrypts the dump with age to your public key before it leaves the container, and writes it to S3-compatible storage, a mounted folder, or both. Backups older than the retention period are then deleted.

  1. Make an age key pair on a trusted machine. Keep the private key offline or in your secrets manager, never next to the backups or on the vault server.
    docker compose run --rm --no-deps clane-vault node dist/src/scripts/age-keygen.js > backup-key.txt
    # or, with age installed: age-keygen -o backup-key.txt
  2. Set the backup variables in .env and run docker compose up -d. The app refuses to start with backups on but no recipient or destination.
    BACKUP_ENABLED=true
    BACKUP_AGE_RECIPIENT=age1…              # your public key
    BACKUP_SCHEDULE=30 2 * * *              # cron, UTC
    BACKUP_RETENTION_DAYS=14
    BACKUP_S3_ENDPOINT=                     # empty for AWS S3
    BACKUP_S3_REGION=us-east-1
    BACKUP_S3_BUCKET=vault-backups
    BACKUP_S3_ACCESS_KEY_ID=…
    BACKUP_S3_SECRET_ACCESS_KEY=…
    # and/or a mounted folder:
    BACKUP_LOCAL_DIR=/backups
  3. Open Admin → Backups in the app and press Back up now. That page shows the status and the run history. Every admin sees a banner if the last backup failed or the last good one is more than 26 hours old.

Use storage in a different account or with a different provider from the database if you can, and turn on object versioning or object lock there, so a compromised server can't erase the history. S3-compatible services such as Supabase Storage, Cloudflare R2 and MinIO work. The image ships pg_dump 17, which backs up Postgres 14 to 17.

Without the built-in schedule, a plain pg_dump works too. Keep copies off the host and encrypted:

pg_dump "postgresql://user:password@host:5432/db" --schema=public | gzip > clane-vault-$(date +%F).sql.gz

Restore

A restore goes into an empty schema, so you can restore next to the live data and switch over when you're satisfied.

docker compose cp ./backup-key.txt clane-vault:/tmp/backup-key.txt
docker compose exec clane-vault node dist/src/scripts/restore.js --list
docker compose exec clane-vault node dist/src/scripts/restore.js \
    --from clane-vault/clane-vault-public-20261006-023000.dump.age \
    --identity /tmp/backup-key.txt --target-schema vault_restored
docker compose exec clane-vault rm /tmp/backup-key.txt
  1. Point DATABASE_URL at the restored schema (?schema=vault_restored), or rename the schemas in Postgres.
  2. Use the same .env as when the backup was taken. Without the same SERVER_SECRET, nobody can use their authenticator codes.
  3. Run docker compose up -d. Any newer migrations run on start. Sign in and open a few records to check.

Test a restore into a scratch schema from time to time, and note how long it takes for your data.

Updates

Take a backup first, then run the update script with the version you want:

Linux or macOS
./update.sh 1.6.2
Windows (PowerShell)
.\update.ps1 1.6.2

The script sets CLANE_VAULT_VERSION in .env, pulls the image and restarts the container. Database migrations run automatically on start. The changelog lists what each release changes and any upgrade steps.

If you'd rather not run it yourself, Clane can host it in the EU, with a data processing agreement. Contact us.