Deploy
How Vektos is online for free at vektos.io, on Vercel and Neon, and what is left out of the copy of the database.
What
Three Vercel projects from one repository, and one Postgres on Neon:
| Address | Vercel project | Root directory | Builds | Config |
|---|---|---|---|---|
| vektos.io: dashboard and chat | vektos-web | apps/web | tsc -b && next build | apps/web/vercel.json |
| docs.vektos.io | vektos-docs | apps/docs | next build | apps/docs/vercel.json |
| storybook.vektos.io | vektos-storybook | packages/ds | storybook build into storybook-static | packages/ds/vercel.json |
Each project was imported from the repository as a single project with its root directory, not
as one "Services" project: three addresses, three builds, and a broken docs build does not take
the dashboard down. Every push to main deploys all three; a pull request gets previews.
Each installs with bun install --frozen-lockfile from its root directory; Bun finds the
workspace above it, and the design system's prepare script generates Panda's styled-system
during the install, as in CI. The benchmark does not run there: the dashboard reads results that
were made on a machine and copied into the database.
The database is a copy of the local one, made by bun run db:export-public: the schema and every
row except the rows that hold secrets (PUBLIC_EXCLUDED in packages/db/src/backup.ts: GitHub's
OAuth tokens, session tokens, sign-in codes). auth_user stays, because shared conversations
belong to a user.
Why
- Free tiers fit. The database is about 80 MB, most of it screenshots; Neon's free tier holds
0.5 GB and has
pgvector. Vercel's free tier builds the three apps. - A pooler needs no prepared statements. Neon's pooled address ends in
-pooler; behind it a prepared statement may meet another connection.connect()turns them off there (isPooled, orDATABASE_POOLED=true). - A copy, not a sync. Runs are made locally and published as a whole; there is no write path from the site to the benchmark.
Try it
The database
As it was done (ch. 72). Neon's two addresses live in Infisical's prod
environment: DATABASE_URL is the pooled one (-pooler), DATABASE_URL_DIRECT the direct one.
Neon and the compose container both run Postgres 18, so the container's tools do the work, and no
address is ever printed:
bun run db:export-public # schema and data, without the auth tables' secrets
F=~/Backups/vektos/vektos-public-<time>.dump
bun run db:restore "$F" # rehearsal: into a new local database; compare the counts
docker cp "$F" vektos-db-1:/tmp/public.dump
infisical run --env=prod -- sh -c 'docker exec -e NEON="$DATABASE_URL_DIRECT" vektos-db-1 \
sh -c "pg_restore --no-owner --no-privileges --exit-on-error -d \"\$NEON\" /tmp/public.dump"'
infisical run --env=prod -- sh -c 'DATABASE_URL="$DATABASE_URL_DIRECT" bun run --cwd packages/db migrate --no-backup'The dump brings the schema and the migrations table with it, so it goes into an empty
database and migrate afterwards has nothing to apply. To check the app against Neon before
Vercel, run it with the pooled address: infisical run --env=prod -- bun run --cwd apps/web dev,
then check:app against it.
Environment
Only vektos-web needs variables, in its Production environment. Infisical's prod environment
is where they come from:
- at its root, every secret of the project;
- in the folder
/vercel/web, the eight the dashboard needs, each a reference to the root's (DATABASE_URLis${prod.DATABASE_URL}), not a copy. What is not in that folder never reaches Vercel:DATABASE_URL_DIRECT(migrations and backups) and the Spaceship key (the DNS).
| Variable | Value |
|---|---|
DATABASE_URL | Neon's pooled address (-pooler) |
BETTER_AUTH_SECRET | openssl rand -base64 32 |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET | A GitHub OAuth app; callback https://vektos.io/api/auth/callback/github |
AI_GATEWAY_API_KEY | A key of its own for production; the chat's cap (VEKTOS_CHAT_BUDGET_USD, default $1) is kept in the database |
BETTER_AUTH_URL, VEKTOS_SITE_URL | https://vektos.io |
VEKTOS_ALLOWED_GITHUB | Who may use the chat |
An Infisical Secret Sync sends that folder to Vercel whenever a value changes. It is made once, in two dashboards (Infisical's sync is not code here):
- Vercel, Account Settings, Tokens: create a token for the team, named after its use.
- Infisical, the project, Integrations, App Connections, Add Connection, Vercel: paste it.
- Infisical, Integrations, Secret Syncs, Add Sync, Vercel. Source: environment
prod, secret path/vercel/web. Destination: that connection, appvektos-web, environment Production. Options: initial sync behaviour "Overwrite Destination Secrets", auto-sync on. - Check it:
vercel env ls production --project vektos-weblists the eight names; redeploy (vercel redeploy <url> --target production); run the check of the live site below.
"Overwrite" removes from Vercel whatever the folder does not hold, so a new variable goes into
the folder before the app needs it: set it at the root of prod (a secret in Infisical's UI, so
its value is never typed on a command line), then point at it:
infisical secrets set --env=prod --path=/vercel/web 'NAME=${prod.NAME}'
infisical run --env=prod --path=/vercel/web -- sh -c 'env | grep -c "^NAME="' # 1: it resolvesA variable added or changed is read by the next deployment, not by the running one. Without the sync, one variable can be copied by hand, its value going from one process to the other through standard input (as in ch. 73):
infisical run --env=prod -- sh -c \
'printf %s "$NAME" | vercel env add NAME production --project vektos-web --type secret'Domains and DNS
vektos.io is registered at Spaceship and its DNS stays there: the zone is signed (DNSSEC, and
it validates), which Vercel's own DNS does not offer (issue #30). Two files in infra/ say what
goes where; both are safe to run again.
bun run infra:domains # each address on its Vercel project; then Vercel's verdict on each
bun run infra:domains -- --dry-run # only say what would change
bun run infra:dns:preview # what a push would change at Spaceship
bun run infra:dns:push # apply it| Host | Record at Spaceship (infra/dnsconfig.js) | Project (infra/domains.ts) |
|---|---|---|
vektos.io | A 216.198.79.1 and A 64.29.17.1 | vektos-web |
www | CNAME 5bf774557587ab47.vercel-dns-017.com | vektos-web, redirecting to vektos.io (308) |
docs | CNAME 0f539ec3b87ed88b.vercel-dns-017.com | vektos-docs |
storybook | CNAME 23498abc1622e295.vercel-dns-017.com | vektos-storybook |
infra/domains.tsuses the Vercel CLI's own sign-in (vercel api), so no token is in the repository. It adds an address a project lacks and sets or clears a redirect; it asks Vercel whether each address is configured correctly, and each redirecting address whether it really answers 308. It prints the record Vercel recommends for each host: the table above is checked against that line. It exits with 1 when something does not hold. It never removes a domain (vercel domains rmtakes it from the whole team, subdomains included): an address a project has and the file does not is named and left alone.infra/dnsconfig.jsis DNSControl's file for the zone (brew install dnscontrol). DNSControl owns the whole zone: a push removes a record that is not in the file. So before the first push, and whenever in doubt,bun run infra:dns:zoneprints the live zone as code, andpreviewmust show no change. The registrar is "none": the nameservers, and with them DNSSEC, are never touched.- The Spaceship key (Spaceship's API Manager; scopes
dnsrecords:read,dnsrecords:write,domains:read) lives at the root of Infisical'sprodasSPACESHIP_API_KEYandSPACESHIP_API_SECRET;infra/creds.jsononly names those variables. Without it,previewsays "missing spaceship api_key or api_secret".
A new address: a line in each file, bun run infra:domains, take the record it prints into
dnsconfig.js, preview, push, infra:domains again. The CNAME targets are each project's
own; take them from that output, not from a tutorial. A domain cannot be given to a project whose
latest production deployment failed: fix the build first.
Check the live site
VEKTOS_APP_URL=https://vektos.io bun test apps/web/src/app.browser.test.ts # 5 pass
vercel logs --project vektos-web --since 10m # what the server said, when a page is wrongExtend it
- Another host. Any Node host runs
next start; every page that reads the tasks (the task pages, and those that filter by domain) needstasks/next to the app. On VerceloutputFileTracingIncludesinnext.config.tsships it with every route,tasks/index.tsincluded: it is what the app looks for to find the folder. - A page that reads a new folder of the repository must add it there too. Nothing fails until production: on a laptop and in CI the whole repository is next to the app (anecdote 28).
- Fresh results. Run the benchmark locally, export again, empty Neon's
publicanddrizzleschemas (drop schema … cascade, thencreate schema public), and restore as above. Back it up first:DATABASE_URL="$DATABASE_URL_DIRECT" bun run db:backupdumps Neon with the container's tools.
Known gaps
- The DNS file has not met the live zone yet.
infra/dnsconfig.jswas written from what the nameservers answer, and they do not list a zone (a transfer is refused; the signed zone hides its names). What they do prove: the apex holds A, NS, SOA and DNSKEY records and no other type, and 21 usual names (_dmarc,mail,_vercel…) do not exist. A record under a name nobody guessed would be removed by a push.previewneeds the Spaceship key and has not run: do notpushbefore it shows no change. - Three scopes, not four. DNSControl's page asks for
domains:writetoo; with no registrar it should not be needed. To confirm at the firstpreview. - The sync is not made yet. The folder and its references are in Infisical; the connection and the sync need a Vercel token and Infisical's UI. Until then the variables on Vercel are the copies made by hand. To check at the first sync: that references arrive resolved, and with which type (Secret or Config) Infisical creates them.
- The sync and the OAuth app are not code. Infisical's sync is made in its UI; GitHub has no API to create an OAuth app.
- Only production was checked. Previews of pull requests are behind Vercel's own sign-in and
have no variables;
check:apphas not been run against one. - The repository is still private. The site is public; the code is not yet.
vector0.8.6 on Neon, 0.8.7 locally. Neon offers its own version; nothing used depends on the difference.- The chat's spend counts from the copy.
chat_usagecomes with the data, so the live chat's cap starts from what was spent locally. - Screenshots live in the database, so they count against its size. Object storage would scale better.