Self-hosting

Run Vestyn on infrastructure you control.

Vestyn runs as a Docker stack with Postgres behind your HTTPS reverse proxy. Put it on a VPS, home server, or NAS — wherever you want your vault data to live.

Requirements

A host with Docker Compose v2, roughly 1GB RAM, and a domain you can point at it. amd64 and arm64 both work.

First run

The first account registered on a new instance becomes the admin — every account after that needs an invite.

Upkeep

Pin VESTYN_IMAGE_TAG to a release, update deliberately, and back up Postgres plus the signing key.

Vestyn ships as two published container images plus Postgres — copy the two files below onto your host and bring the stack up. A note on source availability: Vestyn is licensed under AGPL-3.0, but the source repository is not yet public while the project is in closed alpha — we're doing a security/history review before opening it up. Pre-built images with build provenance and SBOM attestations are published today so you can self-host in the meantime — the exact source that built the current release is downloadable as a Corresponding Source archive.

1 · Create the stack files

Make a directory for the stack and save the compose file into it below. Both files must sit in the same directory — Compose reads .env from the compose file's folder, not from your shell's current folder.

# somewhere on your host mkdir -p /srv/vestyn && cd /srv/vestyn

Save this as docker-compose.yml:

name: vestyn services: db: image: postgres:17-alpine restart: unless-stopped environment: POSTGRES_DB: vestyn POSTGRES_USER: vestyn POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} volumes: - db-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U vestyn -d vestyn"] interval: 5s timeout: 3s retries: 10 security_opt: - no-new-privileges:true api: image: docker.io/mtenekeci/vestyn-server:${VESTYN_IMAGE_TAG:-0.4.200} restart: unless-stopped environment: DATABASE_URL: postgres://vestyn:${POSTGRES_PASSWORD}@db:5432/vestyn VESTYN_DATA_DIR: /data VESTYN_LISTEN: 0.0.0.0:8080 APP_URL: ${APP_URL:-http://localhost:8080} volumes: # holds signing.key — see Backups below - api-data:/data depends_on: db: condition: service_healthy networks: default: # required: nginx in the web image proxies to this name aliases: [vestyn-api] init: true healthcheck: test: ["CMD", "/vestyn-server", "--healthcheck"] interval: 10s timeout: 5s retries: 6 start_period: 15s security_opt: - no-new-privileges:true web: image: docker.io/mtenekeci/vestyn-web:${VESTYN_IMAGE_TAG:-0.4.200} restart: unless-stopped ports: # plain HTTP — put a TLS proxy in front (step 4) - "${VESTYN_PORT:-8080}:80" depends_on: api: condition: service_healthy init: true healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost/healthz"] interval: 10s timeout: 3s retries: 6 start_period: 5s security_opt: - no-new-privileges:true volumes: db-data: api-data:

Corresponding Source for the pinned server/web images above: download the matching source archive.

2 · Configure the environment

Save this as .env, next to the compose file. POSTGRES_PASSWORD is the only required value — the stack refuses to start without it.

# required — generate one with: openssl rand -base64 32 POSTGRES_PASSWORD=paste-a-long-random-string-here # optional — host port the web container publishes (default 8080) # VESTYN_PORT=8080 # optional — set once you have a domain and TLS (step 4) # APP_URL=https://vault.example.com # optional — pin a release instead of the default shown above # VESTYN_IMAGE_TAG=0.4.200

Check the changelog for the latest published version, and keep server and web on the same tag.

3 · Bring it up

Compose pulls the images, starts Postgres, waits for it to become healthy, then starts the API and the web front end. The API runs its database migrations automatically on boot — there is no separate migration step, now or on upgrade.

docker compose up -d # watch it come up docker compose ps docker compose logs -f api

Once api and web report healthy, the vault is live on http://localhost:8080. Put TLS in front before creating an account — your master password is typed into that page.

4 · Terminate TLS

The web container speaks plain HTTP on the published port. TLS is your reverse proxy's job. Caddy gets you automatic certificates in two lines:

vault.example.com { reverse_proxy localhost:8080 }

Traefik and nginx Proxy Manager work equally well — point them at the same port. Then set APP_URL in .env to your real origin so invite links use the right hostname, and restart the API to pick it up:

# .env APP_URL=https://vault.example.com
docker compose up -d api

Traffic between your proxy and the Vestyn container is plain HTTP inside the Docker network — the standard self-host pattern, safe when both run on the same host or a trusted private network. The server only ever holds ciphertext regardless. How the encryption works →

5 · Create the first account

Open your domain in a browser and register. The first account created on a fresh instance becomes the admin — and it is the only account that can ever be created without an invite. Make it yours, not a test account.

To add other people: sign in as the admin, go to Settings → Members & Invites, and create an invite link. Links carry a one-time token, expire after 7 days, and work exactly once. Registration without a valid invite is rejected by the server.

Then open the Mac app and point it at your host on first launch. Download the apps →

6 · Updating

Set VESTYN_IMAGE_TAG in .env to the version you want, then pull and recreate. Keep both images on the same tag.

# from the stack directory docker compose pull docker compose up -d

Migrations run automatically when the new API container boots. Take a database dump before upgrading a production instance — see below.

7 · Backups

Two named volumes hold everything that matters. Vault contents are encrypted on your devices, but a lost database is still a lost vault — the server cannot reconstruct it.

VolumeContents
vestyn_db-data
PostgreSQL
Accounts, vault membership, and every encrypted item. Losing this loses the vault.
vestyn_api-data
signing.key
Signs access tokens. Losing it logs everyone out; it loses no data.

Dump the database on a schedule and keep the dumps somewhere other than this host. --clean --if-exists makes the dump drop existing objects before recreating them, so restoring doesn't collide with the schema migrations already put there:

# nightly dump docker compose exec -T db \ pg_dump -U vestyn --clean --if-exists vestyn | gzip > vestyn-$(date +%F).sql.gz # restore — stop the API first so nothing writes mid-restore, and # fail loudly (ON_ERROR_STOP) instead of continuing past an error docker compose stop api gunzip -c vestyn-2026-01-01.sql.gz | \ docker compose exec -T db psql -U vestyn -v ON_ERROR_STOP=1 vestyn docker compose up -d

Back up the signing key once — it does not change:

docker compose cp api:/data/signing.key ./signing.key.bak

Restoring it needs a helper container rather than docker compose cp, since the file has to land in the volume with the ownership the server's non-root user expects:

docker compose stop api docker run --rm -v vestyn_api-data:/data -v "$PWD":/backup alpine \ sh -c 'cp /backup/signing.key.bak /data/signing.key \ && chown -R 65532:65532 /data && chmod 600 /data/signing.key' docker compose up -d

Restore the database and every account and vault comes back. Restore the signing key too and active sessions survive; skip it and everyone simply logs in again — no data loss either way. Rehearse the restore before you need it.

Stopping

docker compose down stops the stack and keeps your data. docker compose down -v deletes the volumes and everything in them — there is no undo.

How the encryption works →  ·  Download the apps →