Skip to Content
Vektos
Guides

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:

AddressVercel projectRoot directoryBuildsConfig
vektos.io: dashboard and chatvektos-webapps/webtsc -b && next buildapps/web/vercel.json
docs.vektos.iovektos-docsapps/docsnext buildapps/docs/vercel.json
storybook.vektos.iovektos-storybookpackages/dsstorybook build into storybook-staticpackages/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, or DATABASE_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_URL is ${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).
VariableValue
DATABASE_URLNeon's pooled address (-pooler)
BETTER_AUTH_SECRETopenssl rand -base64 32
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRETA GitHub OAuth app; callback https://vektos.io/api/auth/callback/github
AI_GATEWAY_API_KEYA 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_URLhttps://vektos.io
VEKTOS_ALLOWED_GITHUBWho 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):

  1. Vercel, Account Settings, Tokens: create a token for the team, named after its use.
  2. Infisical, the project, Integrations, App Connections, Add Connection, Vercel: paste it.
  3. Infisical, Integrations, Secret Syncs, Add Sync, Vercel. Source: environment prod, secret path /vercel/web. Destination: that connection, app vektos-web, environment Production. Options: initial sync behaviour "Overwrite Destination Secrets", auto-sync on.
  4. Check it: vercel env ls production --project vektos-web lists 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 resolves

A 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
HostRecord at Spaceship (infra/dnsconfig.js)Project (infra/domains.ts)
vektos.ioA 216.198.79.1 and A 64.29.17.1vektos-web
wwwCNAME 5bf774557587ab47.vercel-dns-017.comvektos-web, redirecting to vektos.io (308)
docsCNAME 0f539ec3b87ed88b.vercel-dns-017.comvektos-docs
storybookCNAME 23498abc1622e295.vercel-dns-017.comvektos-storybook
  • infra/domains.ts uses 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 rm takes it from the whole team, subdomains included): an address a project has and the file does not is named and left alone.
  • infra/dnsconfig.js is 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:zone prints the live zone as code, and preview must 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's prod as SPACESHIP_API_KEY and SPACESHIP_API_SECRET; infra/creds.json only names those variables. Without it, preview says "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 wrong

Extend it

  • Another host. Any Node host runs next start; every page that reads the tasks (the task pages, and those that filter by domain) needs tasks/ next to the app. On Vercel outputFileTracingIncludes in next.config.ts ships it with every route, tasks/index.ts included: 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 public and drizzle schemas (drop schema … cascade, then create schema public), and restore as above. Back it up first: DATABASE_URL="$DATABASE_URL_DIRECT" bun run db:backup dumps Neon with the container's tools.

Known gaps

  • The DNS file has not met the live zone yet. infra/dnsconfig.js was 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. preview needs the Spaceship key and has not run: do not push before it shows no change.
  • Three scopes, not four. DNSControl's page asks for domains:write too; with no registrar it should not be needed. To confirm at the first preview.
  • 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:app has not been run against one.
  • The repository is still private. The site is public; the code is not yet.
  • vector 0.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_usage comes 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.

On this page