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:
| Service | What it does |
|---|---|
snapvisor-web | The API and the app UI (pnpm run start:web, listening on port 4001) |
snapvisor-worker | Build processing, screenshot comparison, and notifications (pnpm run start:worker) |
snapvisor-migrate | A one-shot container that runs migrations and seeds the default plans, then exits |
redis | Caching, locks, rate-limit counters, and short-lived codes (email sign-in, OAuth) |
rabbitmq | The job queue between web and worker |
dynamodb | Local 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.
| Variable | Why it fails the boot |
|---|---|
ENCRYPTION_KEY | 32 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_SECRET | Signs the session cookie. Its default is the literal keyboard cat, which the boot check refuses in production. |
STRIPE_PRICING_TABLE_ID | Ships empty and must not be Argos's upstream value. Required even if you never sell anything — see Billing. |
STRIPE_PUBLISHABLE_KEY | Same check, same reason. |
Generate the two secrets:
openssl rand -hex 32 # ENCRYPTION_KEY
openssl rand -hex 32 # SERVER_SESSION_SECRETENCRYPTION_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.comAPI_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).
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 -dThe 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-plansThe 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 ./screenshotsThe @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, throughSLACK_*. 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_KEYunlocks 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.
Open source
What is open, where the source lives, and the README badge for OSS projects.
Getting started
Create a project, install an SDK, and upload your first build.
SDKs
The @snapvisor packages and the environment variables they read.
Access tokens
Project tokens for CI, personal access tokens for the API and MCP.