Snagset

Self-hosting

This is the product. Not a generous edge of a hosted service — the thing itself. There is no crippled open-source edition, no licence key, and no feature that exists only in a paid tier's binary.

Requirements

Node 22 or newer, and a Postgres database. That is the list.

A bucket and a mailer are optional and the product is complete without them: with no bucket you get annotation mode — comments, pins, replies, statuses, rounds, and an element description on every pin — and the widget is told screenshots are unavailable rather than offering one that fails. With no mailer, notifications are off and the dashboard says so; nothing queues up waiting.

Any Postgres 14+ works: Neon, Supabase, RDS, or one you run yourself. If your provider offers a pooled connection string, use it.

Install

npx create-snagset my-review
cd my-review
docker compose up

That is the whole install. Postgres comes with the compose file, so there is nothing to provision first. Open http://localhost:8080 and follow the claim URL printed in the logs.

You get a small repository you own — your .env, your compose file, your Vercel config — that depends on @snagset/server like any other package. Upgrading is npm update. There is no build step, and our source is not in your tree unless you want it there.

Already have a database? Put its URL in .env and skip Docker:

npm install
npm start

Configure

One variable is required:

DATABASE_URL=postgres://user:password@host:5432/snagset

Everything else is optional. Put them in a .env or .env.local next to where you run it — the server reads both, and a real environment variable always wins over a file. .env.example in the repo lists every option with its default.

The three you are most likely to want:

# Where this instance lives. Review links are built from it.
SNAGSET_BASE_URL=https://review.example.com

# Screenshots and uploads. Any S3-compatible bucket.
SNAGSET_STORAGE_S3_ENDPOINT=https://ams3.digitaloceanspaces.com
SNAGSET_STORAGE_S3_BUCKET=my-bucket
SNAGSET_STORAGE_S3_REGION=ams3
SNAGSET_STORAGE_S3_ACCESS_KEY_ID=DO00XXXXXXXX
SNAGSET_STORAGE_S3_SECRET_ACCESS_KEY=…

# Email notifications. The scheme picks the transport.
SNAGSET_MAIL_URL=resend://re_your_api_key
SNAGSET_MAIL_FROM=snagset@example.com

Storage credentials are probed at boot — one write, read and delete of a small object — and a failure is a loud warning rather than a crash, because credentials that are merely well-formed are the ones that fail on the first screenshot, in front of a reviewer, an hour later.

Claim

On first boot the logs print a claim URL. Open it, set a password, and the instance is yours. The URL is single-use and expires.

Or skip the browser entirely. Set these and the instance claims itself:

SNAGSET_BOOTSTRAP_OWNER_EMAIL=you@example.com
SNAGSET_BOOTSTRAP_OWNER_PASSWORD_FILE=/run/secrets/owner-password
SNAGSET_BOOTSTRAP_PROJECT_NAME=Acme redesign
SNAGSET_BOOTSTRAP_PROJECT_ORIGINS=https://staging.acme.com,http://localhost:3000

The password is a file, not a variable: an environment variable is visible in /proc, in docker inspect, and in any crash report that dumps the environment. All four are ignored once the instance has an owner, so they are safe to leave in place across redeploys.

snagset serve claims on boot. To claim without starting the server — a release step, or a one-shot container — run:

npx snagset bootstrap

With those variables set it claims and says so. Without them it mints a claim URL to open instead.

Every setting

npx snagset env

Prints every variable this build accepts, grouped, with what each is currently set to and what it defaults to. It needs no database, so it works before you have provisioned anything — and it is the answer when a typo'd variable stops the server, since the error names the closest real one and points here.

npx snagset env MAIL     # just the ones matching MAIL

Add a site — as many as you like

Sign in, open Sites, and add one. Give it a name and the addresses it runs on. You get the tag back immediately:

<script src="https://review.example.com/snag.js" data-snag-site="prj_…" defer></script>

Paste it into that site, immediately before </body>. Or from a shell:

snagset projects add "Acme redesign" https://staging.acme.com

One instance holds as many sites as you have clients. Each gets its own uid, its own rounds, its own review links, and its own allow-list.

Or install it from npm

Same widget, same behaviour — this writes the tag for you:

npm install snagset
import snagset from "snagset";
snagset.init({ host: "https://review.example.com", site: "prj_…" });

snagset/react and snagset/vue give you <Snagset host="…" site="…" />. snagset/next renders the tag as a server component (or from NEXT_PUBLIC_SNAGSET_* env). snagset/vite is a one-line config plugin — and covers Nuxt, SvelteKit and everything else built on Vite. snagset/astro gives you the tag as data for a layout. Nothing is behind the npm install — if a script tag suits your stack better, use it. It works on WordPress, Webflow, Shopify and Rails, where a React-only tool cannot go.

The allow-list is not optional

Only the addresses you list can talk to the site. A tag on any other address loads and is ignored — which is the hardest failure here to spot from the outside, because nothing is broken and nothing appears. Add them when you create the site, or later from the same screen:

snagset allow-origin prj_… https://www.acme.com

https://*.acme.com covers subdomains. The scheme is required and the port is part of the address.

Where the widget comes from

Your own instance, at the same version as the server — there is no third-party CDN in the path, which is the answer to a security team that will not allow one. /widget/manifest.json lists a sha384 for every file if you need an integrity hash.

Verify

Open the site and press Alt + C. The toolbar appears in the bottom corner. Press +, click anything, type a comment, send it. Reload the page — the pin comes back.

Two commands worth knowing:

npx snagset storage status   # is the bucket reachable? runs a real probe
npx snagset projects         # ids, snippets, and what is allowed to load them

Read the boot banner, especially about row-level security

The server checks, at every boot, whether row-level security is actually in force — and says so plainly when it is not. Read that line rather than assuming, because there is one very common case where it is not:

row-level security is NOT in force: the role "neondb_owner" has BYPASSRLS

Neon — and some other hosted Postgres providers — hand you a connection string for a role carrying the BYPASSRLS attribute. The policies are installed, enabled and correct; the role simply outranks them. The role cannot remove the attribute from itself either (permission denied to alter role), so no migration can fix it on your behalf.

This does not make your instance unsafe. RLS sits behind the application's own scoping, and every query the server issues is scoped by organisation regardless. It means you have one safety net rather than two — a defence-in-depth layer you were told you had. If you want it, create a database role without BYPASSRLS, grant it the schema, and point DATABASE_URL at that role instead of the provider's default one.

The same banner reports two quieter versions of the problem: policies that exist but are not FORCEd (which exempts the owner connection), and tables carrying org_id with no policy at all — the second means "re-run migrations".


Backing up, and putting it back

Two things, and only one of them is the database.

pg_dump "$DATABASE_URL" --no-owner --no-acl > snagset-$(date +%F).sql

That is everything people wrote: sites, rounds, threads, comments, reviewers. Restoring it into an empty database is an ordinary psql restore and needs no special step — the migration table comes with it, so the server starts up and finds nothing to apply.

psql "$NEW_DATABASE_URL" < snagset-2026-08-27.sql

And the signing key, which is not in the dump. If you set SNAGSET_SECRET_KEY it is in your .env; if you did not, the server generated one into .snagset/secret.key on first boot. Either way it is a separate file and it is the half people lose.

What a lost key actually costs

Not the comments — those are plaintext in the database and come back with it. What goes is everything derived from the key:

Restored with the keyRestored without it
Threads, comments, screenshotsfinefine
Reviewer sessionskeep workingdead; everyone re-opens their link
Review linkskeep workingmust be re-issued
Stored bucket credentialsreadableunreadable ciphertext, permanently

The last row is the one that does not recover. It is not a lockout you can reset your way out of — the plaintext is not anywhere else. Measured, not assumed: sealing a value under a backed-up key and opening it under a regenerated one returns nothing at all.

So: back the key up wherever you keep passwords, and check it is there before you need it. A dump you cannot fully use is worse than no dump, because you will find out at the moment you were counting on it.

What this costs to run

Postgres, and a bucket if you want screenshots. There is no queue service, no Redis, no worker process and no third service to sign up for — scheduled work is a bounded function the server calls on a timer, or a cron request on serverless.

Deleting the script tag removes the tool from the site completely. Nothing is left behind. A visitor who is not a reviewer costs one ~300-byte cached check — "is review open here?" — and nothing else, ever.

Support

Community only. There is no support inbox for self-hosting, no SLA and no promise of a reply. The source is AGPL-3.0 and the issue tracker is public; that is the whole of the arrangement, said plainly because it is not the kind of thing that can be quietly introduced later.