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 UseSend. Set USESEND_API_KEY, and USESEND_BASE_URL if you run your own UseSend instance rather than the hosted one.

Without a UseSend 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 [email protected] in apps/backend/src/email/send.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.

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.

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:

pnpm run --filter @argos/backend db:migrate:latest
pnpm run --filter @argos/backend db:bootstrap-default-plans

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.
  • 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