---
title: "Deploy"
description: "Ship the apps on Vercel or your own server."
url: https://docs.skiffr.findmalek.engineer/docs/deploy
markdown: https://docs.skiffr.findmalek.engineer/docs/deploy.md
---

# Deploy

Skiffr deploys as the api, up to four Next apps, and a postgres database. All of them read the same env keys, see [environment](https://docs.skiffr.findmalek.engineer/docs/environment.md).

## 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:

```bash
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:

```bash
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](https://docs.skiffr.findmalek.engineer/docs/observability.md) 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](https://docs.skiffr.findmalek.engineer/docs/environment.md).

4. set the GitHub repo settings below, then run the `deploy` workflow 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.
