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 below. The installer
fetches the compose file, generates the secrets, writes a .env with mode 600 and starts the stack:
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:
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:
BETTER_AUTH_SECRET= # openssl rand -base64 32
BETTER_AUTH_URL=https://shhh.example.com
TRUSTED_PROXY_DEPTH=1 # see "Behind a reverse proxy" below
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-<short commit> 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.
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. ::
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 #
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:
{ "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:
curl -H "Authorization: Bearer $HEALTH_TOKEN" https://shhh.example.com/api/health
{
"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).
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.