Deploy
Ship the apps on Vercel or your own server.
Skiffr deploys as the api, up to four Next apps, and a postgres database. All of them read the same env keys, see environment.
Vercel
Deploy web, dashboard and docs as separate Vercel projects from the same repo.
- import the repo and set the root directory to
apps/web, then repeat forapps/dashboardandapps/docs - add the env keys to each project
- host the
apiwhere bun or docker runs, and pointAPI_URLandAPI_PUBLIC_URLat it
Docker
Every app has a Dockerfile in apps/<app>/. The build context is the repo root and turbo prunes the workspace to what the app needs:
docker build -f apps/web/Dockerfile -t skiffr-web .
docker build -f apps/dashboard/Dockerfile -t skiffr-dashboard .
docker build -f apps/api/Dockerfile -t skiffr-api .
docker build -f apps/docs/Dockerfile -t skiffr-docs .Next apps are built with output: "standalone" and ship a healthcheck. Pass the env at run time.
Dokploy
Create a project with a postgres database on the docker image pgvector/pgvector:pg17 (answer embeddings need the vector extension), add one application per app with build type Dockerfile, path apps/<app>/Dockerfile and . as the context, set the env keys, and attach your domains. Use the api service's internal address for API_URL.
Do not swap the image of an existing database in place. The pgvector image uses a different libc than an alpine based postgres image, so text btree indexes silently break on the old data. Create a new postgres service with its own volume on pgvector/pgvector:pg17 and restore with pg_dump / pg_restore, or at least run REINDEX DATABASE after any libc change.
vector is not a trusted extension, so CREATE EXTENSION vector needs a superuser. If the app's database user is not one, run it once as superuser, the migration's IF NOT EXISTS is then a no-op.
Client address behind Traefik
The sign-in limits are per client address. Traefik replaces any X-Forwarded-For a client sends with the address it saw (unless you set forwardedHeaders.trustedIPs or insecure on the entrypoint, which you should not), so the api gets one value and the default TRUSTED_PROXY_COUNT=1 is right. The dashboard passes the header on untouched. If a proxy of your own sits in front of Traefik and appends to the header, raise the count by one for each. If the api is reachable without any proxy, set TRUSTED_PROXY_COUNT=0, or a client could pick its own address. A request with no usable address shares one looser bucket and the api logs a warning once: check that with select key from rate_limit after a few sign-ins, you should see real addresses and not 192.0.2.1.
Self-hosting with GitHub Actions
.github/workflows/deploy.yml builds the four images on every green push to main, pushes them to ghcr.io/<owner>/skiffr-<app> (tags main and sha-<commit>), redeploys the Dokploy applications and then checks /health on the api and the other three sites. The api migrates the database when it starts. Nothing runs until the repo variable DEPLOY_ENABLED is true.
One-time setup
- point four DNS records at your server (a CNAME to its public name or an A record): the base domain for the landing page, and
dashboard.,api.anddocs.under it. Traefik issues the certificates through Let's Encrypt, so ports 80 and 443 must reach Dokploy - create a Dokploy API key (Settings, Profile, API keys)
- run the bootstrap, first without
--applyto read the plan:
DOKPLOY_URL=https://dokploy.example.com DOKPLOY_TOKEN=... \
node scripts/dokploy-bootstrap.mjs --domain skiffr.example.com --env-file .env.production
# looks right? add --applyIt creates the skiffr project, a postgres on pgvector/pgvector:pg17 (its user is the superuser the vector extension needs), the skiffr-api, skiffr-web, skiffr-dashboard and skiffr-docs applications, their domains and their env. It is safe to run again: existing things are kept and an env key that is already set in Dokploy is never overwritten. BETTER_AUTH_SECRET, the database password and, when ADMIN_EMAIL is given without a password, ADMIN_PASSWORD are generated and live in Dokploy only. The script prints key names, never values.
--env-file is optional and only these keys are read from it: ADMIN_EMAIL, ADMIN_PASSWORD, EXTENSION_ORIGINS, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_TOKEN, the S3_* keys, the three Gmail keys, AI_MODEL, GROQ_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY and the observability keys (GLITCHTIP_DSN, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, AI_TRACE_CAPTURE_CONTENT, UMAMI_URL, and per app UMAMI_<APP>_WEBSITE_ID, GLITCHTIP_WEB_DSN, GLITCHTIP_DASHBOARD_DSN). Anything left out stays unset and the api still boots, see environment.
- set the GitHub repo settings below, then run the
deployworkflow once from the Actions tab
| Where | Name | Value |
|---|---|---|
| variable | DEPLOY_ENABLED | true |
| variable | PRODUCTION_DOMAIN | the base domain, for example skiffr.example.com |
| variable | DOKPLOY_URL | the public url of Dokploy |
| secret | DOKPLOY_TOKEN | the Dokploy API key |
| variable, optional | TURBO_API, TURBO_TEAM | a turbo remote cache for the image builds |
| secret, optional | TURBO_TOKEN, GHCR_READ_TOKEN | the cache token, and a read:packages token if the ghcr packages are private |
DOKPLOY_URL, DOKPLOY_TOKEN are the same settings the PR previews use.
Back up the database from Dokploy (Backups tab of the postgres service) to an S3 destination. The backup workflow only works against a database that is reachable from GitHub.
Rollback
A deploy that dies in its first seconds keeps the old container (Dokploy services start first and roll back on failure). To go back to an older version set the previous sha- tag on the application in Dokploy, or run the workflow again on an older commit. A bad migration is not rolled back.