skiffr

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.

  1. import the repo and set the root directory to apps/web, then repeat for apps/dashboard and apps/docs
  2. add the env keys to each project
  3. host the api where bun or docker runs, and point API_URL and API_PUBLIC_URL at 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

  1. 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. and docs. under it. Traefik issues the certificates through Let's Encrypt, so ports 80 and 443 must reach Dokploy
  2. create a Dokploy API key (Settings, Profile, API keys)
  3. run the bootstrap, first without --apply to 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 --apply

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

  1. set the GitHub repo settings below, then run the deploy workflow once from the Actions tab
WhereNameValue
variableDEPLOY_ENABLEDtrue
variablePRODUCTION_DOMAINthe base domain, for example skiffr.example.com
variableDOKPLOY_URLthe public url of Dokploy
secretDOKPLOY_TOKENthe Dokploy API key
variable, optionalTURBO_API, TURBO_TEAMa turbo remote cache for the image builds
secret, optionalTURBO_TOKEN, GHCR_READ_TOKENthe 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.

On this page