•
10 min read
Building turbo-vibe

Well, first of all I’m super happy that I’m back at blogging after some time.

In the last few months, I’ve been involved in multiple software projects, for which I ended up recreating deployment scripts and pipelines. Sometimes they involved simple static pages, some other times they involved APIs, Telegram bots, cron jobs, databases, etc. — and I found myself recreating scripts, asking Claude to generate the same yaml files for GitHub Actions.

Since I’m incredibly lazy, I decided to pack all my learnings in turbo-vibe.

turbo-vibe: life after localhost

What?

turbo-vibe is a Node.js CLI that, given a YAML file describing your architecture, allows you to deploy your application on Vercel, Cloudflare, Supabase and WorkOS. And it can do that in minutes, if you have your accounts ready. Otherwise it will take a matter of hours, worst case!

Technically, it’s a collection of scripts, IaC descriptors (OpenTofu), GitHub Actions, skills for your coding agents (Claude and Cursor are supported for now), all managed by the CLI, useful for the said goal.

Why?

One of the projects I worked on in the last few months was an MVP that had to be deployed on a dedicated Hetzner VPS (client requirement). I spent 3 weeks on shaping the product, speaking with end customers, coding an MVP by using Claude and Codex (more on that in another post) and syncing daily with my direct customer. After all of that, I assumed that deploying would take 1 full day. Well, I was damn wrong.

The app worked locally, but I had to:

  • create staging and production environments, each with their own database
  • set up preview environments on PRs
  • protect staging from public access
  • manage secrets in CI
  • deal with DNS
  • release continuously
  • manage rollbacks

It took 3 days and I found myself repeating roughly the same for subsequent projects. Since I’m lazy, I had to do something about it.

One file, your accounts

When running npx @turbo-vibe/cli init, you will be guided to set up the accounts needed based on your architecture, and asked questions with the goal of generating (among other files) the turbo-vibe.yaml descriptor:

# yaml-language-server: $schema=https://unpkg.com/@turbo-vibe/cli@1/schema/turbo-vibe.schema.json
version: 1
project: calleri-dev
domain: calleri.dev

environments:
  staging:
    hostname: staging.calleri.dev
    access:
      emails: [me@example.com]
  production:
    analytics: true
services:
  web:
    kind: frontend
    target: cloudflare-static
    build:
      command: npm ci && npm run build   # a default: your build command
      output: dist   # a default: your build's output directory

The file declares your architecture and supports:

  • protected staging and production environments
  • preview environments on your PRs out of the box (with dedicated releases and DB branches)
  • separate DBs for staging and production
  • analytics (Cloudflare at the moment, Vercel coming soon)
  • multiple deploy targets: Cloudflare, Vercel, Supabase for DBs and WorkOS for auth
  • customized build commands

During the setup process, you will be asked to generate tokens for your vendors. They will be useful for:

  • turbo-vibe to automate the setup
  • GitHub to set up continuous deployment

Where possible, they just need specific permissions for the zone (e.g., calleri.dev) and not the whole account.

Everything lives in your own accounts: turbo-vibe just glues them all together and saves the OpenTofu state (Terraform) in a Cloudflare R2 bucket. This also means there’s no real lock-in: you can just remove the file and pipelines and you’re back to your accounts and your own way to deploy. Well, if you want to manage it all on your own of course.

And… if you have a project that doesn’t fit some of the requirements, the adopt command will also generate skills for your coding agent that will help you adapt your project to be deployable with turbo-vibe.

Build once and promote

Once the setup is complete, this is what you can expect:

  • Preview the feature on your PR: environments are created on the fly and killed once the branch closes.
  • Check your feature on staging once you merge into the main branch: Supabase migrations are applied automatically on staging before the backend deploy.
  • Promote the staging build to the production env, with your production env variables: what you tested on staging is exactly what ships to your users.
  • Roll back by using the ship command or through GitHub Actions.
  • Smoke tests for both staging and production, with GitHub pipelines failing if they don’t pass.

War stories

I started using this on a production project after 2 days, then expanded and refined the feature set in a few weeks as I migrated to others (including this one), and learned a few things along the way.

1. “Deployed” doesn’t mean “running”

After the first Cloudflare deployment, my smoke tests started failing repeatedly.

wrangler is Cloudflare’s CLI, used by turbo-vibe for deployments. Since you can scale your backend horizontally to an arbitrary number of containers, wrangler deploy returns as soon as the deployment starts — meaning that the rollout might not be completed yet. On top of that, the default rollout is gradual, with only 10% of the instances replaced immediately and the rest later.

Also, when the rollout starts, Cloudflare sends a SIGTERM signal to the old containers, but if your app ignores it, Cloudflare waits up to 15 minutes before getting rid of them — and the rollout waits with it.

As a result, the smoke test calling GET /_turbo-vibe/health (which asks the container which build it’s running) could hit a random container still running the old build.

The fix came in a few steps:

  • Roll out to every instance at once: turbo-vibe sets the rollout to immediate instead of the gradual default.
  • Follow the rollout: after wrangler deploy, turbo-vibe follows the rollout it just started through Cloudflare’s API, until every instance runs the new image.
  • Name instances after the build: even then, Cloudflare reported every instance on the new image while a long-lived instance kept serving the old container for minutes. Now, when requests are routed to multiple instances, these are named after the build ID, so new requests land on fresh containers straight away.
  • Check SIGTERM before deploying: deploy --dry-run starts your container locally and fails if it doesn’t stop on SIGTERM.
  • Be patient: the smoke test retries every 6 seconds — for about a minute normally, and up to 5 minutes while the container is still starting or still reports the previous build.

Lesson learned: “deployed” means nothing until the running service tells you which build it is.

2. The DB roles that locked themselves out

For security reasons, turbo-vibe uses 2 separate Postgres roles on Supabase: one for migrations — only used by CI; the other used by the application — only allowed to read and write rows.

An early (not released) turbo-vibe version carried the roles’ passwords in plain text in the migration scripts:

ALTER ROLE turbo_vibe_migrator WITH LOGIN PASSWORD '...';

By checking the migrate command run, I noticed the line above, with the potential consequence that every user with access to GitHub could see the passwords in plain text — and potentially access the data. Not good. I asked Claude to find a solution and run a /security-review on the whole change. That’s where I learned about the SCRAM protocol. In layman’s terms, it’s a mechanism for exchanging a password securely between 2 parties.

Here’s the logic a client uses for generating a SCRAM verifier starting from a password and a salt:

SaltedPassword = PBKDF2-SHA256(password, salt, 4096)
StoredKey      = SHA256(HMAC(SaltedPassword, "Client Key"))
ServerKey      = HMAC(SaltedPassword, "Server Key")
verifier       = SCRAM-SHA-256$4096:<salt>$<StoredKey>:<ServerKey>

TL;DR: turbo-vibe runs these lines itself and hands Postgres only the last one, the verifier. Postgres stores it in place of the password and uses it to check every login, so the password never appears in the migration script or in the CI logs. And since hashes only work one way, nobody can work out the password from the verifier. At login, the client recomputes the keys from the password and sends a one-time proof, which Postgres checks against the verifier.

Postgres supports using SCRAM verifiers in place of plain text passwords, so the plain-text password issue was solved and the first deploy worked. However, here lay another issue.

Originally the SCRAM verifier (and thus the password and salt) was re-generated during each deploy. The second deploy locked the roles out and I let Claude investigate the issue. A direct connection to Postgres worked, but the issue lay in the Supabase connection pooler. Apparently the pooler (Supavisor) can temporarily cache SCRAM verifiers, causing connections to fail until the cache expired. So a deploy happening at time x caused the verifier to be cached, with the next deploy at time x + 1 correctly updating the verifier, but not available yet on the pooler.

To solve this I let OpenTofu store both a random password and a random salt:

resource "random_password" "role" {
  for_each = toset(["migrator", "runtime"])

  length  = 40
  special = false
}

# Each role's SCRAM salt is random but kept across migrates: Supabase's pooler caches the verifier, so a new
# salt on every run locks the role out.
resource "random_bytes" "salt" {
  for_each = toset(["migrator", "runtime"])

  length = 16

  lifecycle {
    replace_triggered_by = [random_password.role[each.key]]
  }
}

This stabilized both, with the cache never becoming stale across deploys. And in case some of the attributes change (for example the length), the replace_triggered_by will ensure that not only a new password will be rotated, but also a new salt, so they always come in pairs and there’s no risk of locking out the roles again.

What’s next

Finally, I’m happy that thanks to my laziness I’m now able to deploy my next project in a matter of minutes.

I’d be really happy if you want to test it too. As a quick recap:

  • The CLI is open source, MIT-licensed and available on npm.
  • GitHub builds and releases an automated COMPATIBILITY.md table every week to make sure the tool stays up and running against all vendors.
  • Vercel targets are currently in beta, and I will release an updated version in the next few weeks.
  • Support for other vendors coming soon.

Try npx @turbo-vibe/cli init on your next project and don’t forget to open issues and tell me what’s broken.

Have fun!