Snapvisor Docs

Self-hosting

Run your own Snapvisor instance with Docker Compose - the services it needs, the environment variables it refuses to boot without, and how to point the SDKs at it.

Snapvisor is self-hostable. The repository ships a Dockerfile and a production docker-compose.yml, so you can run the same web app, worker, and diff engine that power the hosted service on your own infrastructure — useful when screenshots of your product can't leave your network.

This is a real deployment rather than a one-click install: you bring a Postgres database, an S3-compatible bucket, and a TLS-terminating proxy, and you set a handful of secrets that the backend refuses to start without. Everything below is what the repository actually ships.

Getting the source. The @snapvisor SDKs you point at your instance are public, but the server repository — the Dockerfile and Compose file this guide describes — is not yet open to the public. Reach out through Support to arrange access before working through the steps below.

What you'll be running

docker-compose.yml defines six services:

ServiceWhat it does
snapvisor-webThe API and the app UI (pnpm run start:web, listening on port 4001)
snapvisor-workerBuild processing, screenshot comparison, and notifications (pnpm run start:worker)
snapvisor-migrateA one-shot container that runs migrations and seeds the default plans, then exits
redisCaching, locks, rate-limit counters, and short-lived codes (email sign-in, OAuth)
rabbitmqThe job queue between web and worker
dynamodbLocal DynamoDB, used only by the deployments feature

Screenshot comparison runs inside the worker — there is no separate diff service to deploy.

Two things are deliberately not in the Compose file, because you supply them:

  • Postgres, reached over DATABASE_URL. Point it at a managed database or a container you run yourself.
  • An S3-compatible bucket for screenshots. AWS S3, MinIO, and Backblaze B2 all work (B2 needs one extra setting — see Storage).

The snapvisor-web and snapvisor-worker services join an external Docker network named dokploy-network, and snapvisor-web only exposes port 4001 rather than publishing it. That is how the hosted deployment is wired. If you are not running behind the same proxy setup, either create that network (docker network create dokploy-network) or edit the networks: blocks and publish port 4001 yourself before docker compose up will succeed.

Environment variables

.env.example at the repository root is the full vocabulary — every variable the backend reads, with comments. The sections below cover the ones that matter for a working self-hosted instance.

The four that stop the boot

In production the backend validates its configuration before it serves anything and throws with a named variable if any of these is missing or left at its committed default. A crash-looping container after your first docker compose up is almost always one of them.

VariableWhy it fails the boot
ENCRYPTION_KEY32 bytes as 64 hex characters, used to encrypt sensitive columns at rest. The committed default is an all-zero key that is syntactically valid, so the boot check rejects that exact value rather than letting you run on a key anyone can read.
SERVER_SESSION_SECRETSigns the session cookie. Its default is the literal keyboard cat, which the boot check refuses in production.
STRIPE_PRICING_TABLE_IDShips empty and must not be Argos's upstream value. Required even if you never sell anything — see Billing.
STRIPE_PUBLISHABLE_KEYSame check, same reason.

Generate the two secrets:

openssl rand -hex 32   # ENCRYPTION_KEY
openssl rand -hex 32   # SERVER_SESSION_SECRET

ENCRYPTION_KEY is permanent. Rotating or losing it makes every encrypted column unreadable, and there is no recovery path — the data is gone. Store it in a secrets manager before your first deploy, and back it up with the same care as the database itself.

URLs and sessions

SERVER_URL=https://snapvisor.example.com
API_BASE_URL=https://api.snapvisor.example.com
SESSION_DOMAIN=.example.com

API_BASE_URL must be a different subdomain from SERVER_URL. The backend router matches incoming requests against that host to decide whether it is serving the API or the app, so pointing both at the same origin breaks routing.

Storage

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_SCREENSHOTS_BUCKET=
S3_ENDPOINT=
S3_REGION=
S3_PUBLIC_IMAGE_BASE_URL=

S3_PUBLIC_IMAGE_BASE_URL is the origin the app builds image URLs from, so it has to be publicly reachable by whoever reviews builds in a browser.

On Backblaze B2, also set S3_SUPPORTS_PRESIGNED_POST=false. B2 does not implement the S3 POST object operation and answers 501 NotImplemented, which breaks every screenshot upload. With the flag off, the backend hands clients a proxied POST /v2/uploads target instead and streams uploads to the bucket itself. AWS S3 and MinIO implement POST object, so leave the default (true) there.

Because a proxied build can post thousands of individual screenshots from one CI IP, uploads get their own rate-limit budget via API_RATE_LIMIT_UPLOADS_LIMIT (default 10000).

Email

Transactional email — sign-in codes, invitations, and notifications — goes through Sendly. Set SENDLY_API_KEY, and SENDLY_BASE_URL if you run your own Sendly instance rather than the hosted one.

Without a Sendly key the app still boots and still generates email sign-in codes, but nothing delivers them: the send path logs an error and returns. Either configure email, or sign in exclusively through the Google, GitHub, or GitLab OAuth providers. Note also that the sender address is currently hardcoded to no_reply@snapvisor.io in apps/backend/src/email/sender.ts — change it there and rebuild if you send from your own domain.

Billing is off by default

Stripe billing stays disabled while STRIPE_API_KEY is empty or a placeholder. The check in apps/backend/src/billing.ts treats no-api-key, sk_placeholder, whsec_XXX, and whsec_placeholder as "not configured", the checkout flow is short-circuited, and every team stays on the free plan. This is the expected mode for a self-hosted instance — you are not billing anyone, so leave the API key and webhook secret unset.

What "every team stays on the free plan" means in practice:

  • The Free plan's cap applies. The seeded free plan includes 7,000 screenshots a month and is flat-rate, not usage-based (apps/backend/src/database/default-plans.ts). Once an account passes 110% of that in the current month, createBuild rejects new builds with a 402 ("You have reached the maximum screenshot capacity included in your Free Plan"). The cap is per account (a user or a team), not per instance.
  • Editing the plan row will not stick. snapvisor-migrate re-applies the seeded plans on every deploy and overwrites existing rows with the same names. The one mechanism in the code for lifting the cap is the accounts."forcedPlanId" database column, which pins an account to another plan; nothing in the app sets it, so it is a manual database change.
  • No paywall. Creating a team skips checkout and trial, the MCP plan gate is not enforced (see MCP), and the subscription status reads as active.
  • SAML SSO cannot be switched on from the UI. The SAML add-on is bought through Stripe, so without Stripe the UI refuses with "Your plan does not allow enabling SAML SSO". Access is checked against the samlIncluded plan flag and the samlPurchased team flag, which a self-hosted instance would have to set directly in the database. GitHub SSO is included in the Free plan and needs no Stripe, but does need your GitHub App.

That is separate from STRIPE_PRICING_TABLE_ID and STRIPE_PUBLISHABLE_KEY, which are boot requirements regardless. Set them to your own values from the Stripe pricing tables dashboard; a sandbox pricing table is fine and costs nothing. The check exists so that an instance can never silently render — and bill through — another company's live Stripe account.

MCP

The MCP server is part of the same web process: it answers on the host whose first label matches MCP_BASE_URL (apps/backend/src/web/app.ts, apps/backend/src/web/util.ts). MCP_BASE_URL drives the OAuth resource metadata, and its default is upstream's host, not yours, so set it and route that hostname to port 4001:

MCP_BASE_URL=https://mcp.example.com

On the hosted service, MCP review and team tools need a paid plan, and the agent build loop (create project, create/update/finalize builds, baseline, build and diff reads) is on Free. That plan gate does nothing when Stripe billing is not configured (apps/backend/src/auth/mcp.ts), so a self-hosted instance serves every MCP tool to every account.

Analytics and error reporting

Error reporting is off until you set SENTRY_SERVER_DSN and SENTRY_CLIENT_DSN. Product analytics needs a decision from you, because the shipped docker-compose.yml bakes Snapvisor's own browser keys into the frontend build when you do not set them:

  • PostHog. The snapvisor-web build args default VITE_PUBLIC_POSTHOG_KEY to Snapvisor's project key and VITE_PUBLIC_POSTHOG_HOST to https://posthog.devino.ca. With POSTHOG_KEY empty the browser falls back to that build-time key (apps/frontend/src/analytics.ts) and sends events through your server's first-party proxy to POSTHOG_HOST. To turn it off, set POSTHOG_DATASET=disabled; set VITE_PUBLIC_POSTHOG_KEY= (empty) at build time as well to remove the key from the bundle.
  • Google Analytics 4. GA4_MEASUREMENT_ID defaults to Snapvisor's measurement id in the Compose file when the variable is unset. Copying .env.example sets it to empty, which disables GA4; if you do not use that file, set it to an empty value yourself.

Brand origins

BRAND_DOCS_ORIGIN and BRAND_MARKETING_ORIGIN control where in-app links to docs and marketing point. They default to the upstream Argos site, which is deliberate. If you override them, point them at a subdomain, never at the same origin your app answers on — links back into the app's own routes corrupt the stored "last visited account" and send users to a 404 on their next login. An invariant check fails the build if either is set to the app's origin.

First run

cp .env.example .env
# fill in the variables above, then:
docker compose up -d

The snapvisor-migrate service runs scripts/release.sh, which does two things against DATABASE_URL and then exits:

# from apps/backend, with node directly (no pnpm at run time)
node node_modules/knex/bin/cli.js migrate:latest
node dist/database/bin/bootstrap-default-plans.js

The first applies every migration; the second seeds the baseline plans a fresh database needs before any account can be created. It runs on every deploy and is safe to repeat. The service is marked restart: "no" on purpose — it is expected to exit 0 and stay stopped, not to stay up.

Check that the web container came up healthy before going further. If it is restarting, read its logs: the boot checks name the exact variable they are unhappy about.

Your first build

Create an account through the sign-in page, then create a project and copy its project token from the project's settings.

Now point the CLI at your instance. There is no command-line flag for this — the API base URL comes from the ARGOS_API_BASE_URL environment variable, and it includes the /v2/ path:

export ARGOS_TOKEN="<your project token>"
export ARGOS_API_BASE_URL="https://api.snapvisor.example.com/v2/"

npx @snapvisor/cli upload ./screenshots

The @snapvisor SDKs keep the ARGOS_* variable names, so any pipeline already wired for Argos or hosted Snapvisor works against your instance once that one variable is set. Everything on Getting started and SDKs applies unchanged from here.

What this guide does not cover

Self-hosting hands you a few responsibilities that the hosted service absorbs:

  • TLS and reverse proxying are yours. Compose exposes the web container on 4001 without a certificate. Terminate TLS in front of it and route your app and API subdomains to that port.
  • Git provider integrations need your own OAuth apps. Pull-request status checks, repository linking, and sign-in through a provider require registering a GitHub App, a GitLab application, or a Google OAuth client of your own and supplying the matching credentials (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_WEBHOOK_SECRET, GITHUB_APP_URL; GITLAB_APP_ID, GITLAB_APP_SECRET, GITLAB_ARGOS_AUTH_SECRET; GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET). Slack notifications work the same way, through SLACK_*. Without them the app runs fine — you just upload builds and review them in the UI instead of on a pull request.
  • Deployments are not turnkey. The feature that hosts a static build (a Storybook, say) on its own URL is served by an AWS CloudFront and Lambda@Edge stack defined under infra/ (infra/lib/deployment-stack.ts, infra/lambda/resolve-alias-viewer-request.ts), with its own bucket, base domain and access-token secret (DEPLOYMENTS_*). The Compose file adds only a local DynamoDB; it does not deploy that stack.
  • Backups are yours. Postgres holds every build and review; the bucket holds every screenshot; ENCRYPTION_KEY unlocks the encrypted columns. All three need to survive together or none of them are useful.
  • Upgrades are yours. Pull, rebuild, and let the migrate service run before the new web and worker containers take traffic.

On this page