--- title: Installation description: Deploy shhh with Docker Compose, or against an existing PostgreSQL. navigation: icon: i-lucide-container --- ## Requirements - Docker with Compose - A PostgreSQL 14+ database — one is bundled if you don't have one - A domain with HTTPS, for anything beyond local testing ## Quick start No checkout needed — the image is published, see [Registries](#registries) below. The installer fetches the compose file, generates the secrets, writes a `.env` with mode 600 and starts the stack: ```bash mkdir shhh && cd shhh curl -fsSLO https://raw.githubusercontent.com/thoda-dev/shhh/master/install.sh less install.sh && sh install.sh ``` It asks for the public URL, whether to use the bundled PostgreSQL, how many proxies sit in front, and the Turnstile keys. Set `BETTER_AUTH_URL`, `SHHH_BUNDLED_DB`, `SHHH_PROXY_DEPTH`, `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` and `SHHH_START` in the environment and it asks nothing, which is what makes it usable from cloud-init or Ansible. ### By hand Two files, no script: ```bash curl -O https://raw.githubusercontent.com/thoda-dev/shhh/master/docker/docker-compose.yml curl -o .env https://raw.githubusercontent.com/thoda-dev/shhh/master/.env.example ``` Then fill in `.env` and bring it up: ```bash [.env] BETTER_AUTH_SECRET= # openssl rand -base64 32 BETTER_AUTH_URL=https://shhh.example.com TRUSTED_PROXY_DEPTH=1 # see "Behind a reverse proxy" below ``` ```bash docker compose up -d ``` Open your instance. The **setup wizard** walks you through creating the super admin account and the initial limits. It only runs once — afterwards it redirects to the app. ::note Database migrations are applied automatically on every boot, so upgrades are `docker compose pull && docker compose up -d` with no manual step. Concurrent starts are serialised with a PostgreSQL advisory lock. :: ### Registries Both images are published to two registries, under the same version tags: | Registry | App | Docs | | --- | --- | --- | | GHCR | `ghcr.io/thoda-dev/shhh` | `ghcr.io/thoda-dev/shhh-docs` | | Docker Hub | `thodadev/shhh` | `thodadev/shhh-docs` | The compose file points at GHCR. Docker Hub limits anonymous pulls per IP address, and an ISP using CGNAT hands the same address to many subscribers, so the limit can be reached by people who are not you. GHCR applies no such limit to public packages. Swapping the `image:` line for the Docker Hub name changes nothing else: a release builds each image once and pushes the identical manifest to both, so the two never drift. The documentation image also moves on its own. A correction to a sentence does not deserve a version of shhh, so `shhh-docs:latest` is republished between releases, alongside a `sha-` tag that never moves. The version tags are written only by a release and never rewritten, so `shhh-docs:1.1.9` keeps meaning "the documentation as it shipped with that version of the app". Pull `:latest` for the current documentation, or a version tag to match a pinned instance. ## Using an existing PostgreSQL Point `DATABASE_URL` at your server, then remove the bundled database from `docker/docker-compose.yml`: the `db` service, the `depends_on` block on `app`, and the `db-data` volume. ```bash [.env] DATABASE_URL=postgres://user:password@db.internal:5432/shhh ``` Nothing else changes — the app only ever reaches the database through that one variable. It needs permission to create tables in the target schema. ## Behind a reverse proxy Terminate TLS at your proxy and set `BETTER_AUTH_URL` to the public HTTPS address. That value is also used to build the links sent by email, so it has to be the address your users actually reach. Set `TRUSTED_PROXY_DEPTH` to the number of proxies you control in front of the app: `1` for the setup below, `2` if Cloudflare sits in front of it too. Rate limiting, the IP allow/blocklist and automatic bans all key on the address it resolves. ::warning Counting wrong breaks one of the two things. Too low, and every request looks like it came from your proxy, so a single caller exhausts everyone's rate limit. Too high, and the app reads an entry the client wrote, so a caller picks their own address — and can get somebody else's banned. :: ```nginx [/etc/nginx/sites-available/shhh] server { listen 443 ssl; server_name shhh.example.com; # Certificates: certbot --nginx writes these two lines for you. ssl_certificate /etc/letsencrypt/live/shhh.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/shhh.example.com/privkey.pem; # Uploads travel as base64 inside JSON, so the body is roughly 1.4x the file. nginx defaults to # 1m and would reject them with its own 413 before the app could answer with a useful message. # Keep this comfortably above max_upload_size_bytes in the admin dashboard. client_max_body_size 10m; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; # Appends this connection's address to whatever the client sent. Harmless: with # TRUSTED_PROXY_DEPTH=1 the app counts one entry from the right and lands on this one, # ignoring anything prepended. `$remote_addr` works too, and overwrites instead. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } # Anything on port 80 exists only to send people to 443. The fragment key lives in the URL. server { listen 80; server_name shhh.example.com; return 301 https://$host$request_uri; } ``` Then `TRUSTED_PROXY_DEPTH=1` in `.env`, and restart. To check it took, ban an address from the admin dashboard from another network and confirm only that address is refused. ## Upgrading ```bash docker compose pull docker compose up -d ``` Migrations run on boot. Take a database backup first; there is no automatic rollback. `latest` never points at a pre-release. Pin the tag to a version in `docker-compose.yml` to control when you move. ## Health checks `GET /api/health` is public and reports whether the instance and its database are up: ```json { "status": "ok", "db": "ok" } ``` It returns `503` when the database is unreachable. The path is exempt from bot detection, so monitoring agents are not banned for probing it. Storage usage and the mail provider describe your instance to anyone who asks, so they sit behind `HEALTH_TOKEN`. Set one and pass it to read them: ```bash curl -H "Authorization: Bearer $HEALTH_TOKEN" https://shhh.example.com/api/health ``` ```json { "status": "ok", "db": "ok", "mail": "smtp", "storage": { "usedBytes": 148213, "quotaBytes": 40000000000 } } ``` With no token configured those two fields are simply absent. ## Building from source Node 24 and pnpm 11 are required (`mise install` picks up both). ```bash pnpm install pnpm db:migrate pnpm dev # http://localhost:3000 pnpm typecheck ``` `pnpm dev` and `pnpm build` route through a wrapper that injects secrets via the Infisical CLI when a `.infisical.json` is present, and runs Nuxt directly otherwise. That file is gitignored and per-developer, so a fresh checkout uses the `.env` above.