Quickstart
From a fresh clone to a running instance, in one command.
Before you start
| Node.js | 22 or newer |
| pnpm | 8 or newer |
| Docker | For PostgreSQL |
Node 22 is a floor rather than a preference. The agent runs on the GitHub Copilot
CLI, which Terrablox starts as a child process using the same Node that runs the
app, and that CLI needs Promise.withResolvers — added in Node 22. On Node 20 every
page loads and the first chat message fails.
You will also want a GitHub App, which the script asks for. Registering one takes a couple of minutes and the next section explains it.
Run it
git clone https://github.com/t3bld/terrablox.git
cd terrablox
pnpm quickstart
That single command checks the prerequisites, writes the .env files, generates a
session secret, installs dependencies, starts PostgreSQL in Docker, applies the
database migrations, offers to import the module catalogue and then starts the dev
server. It is safe to re-run: existing .env files and existing data are never
overwritten.
The dashboard is then at http://localhost:3001.
Registering the GitHub App
GitHub sign-in is required — it is the only way in, and the same grant is what lets the agent read and write your repositories. Without it the app starts and the login page can do nothing.
It has to be a GitHub App. A classic OAuth App will let you sign in and nothing more: Terrablox asks GitHub which repositories the installation was granted, and those endpoints reject an OAuth token, so every repository picker comes back empty.
Register one at https://github.com/settings/apps/new, or for an organisation at
https://github.com/organizations/<your-org>/settings/apps/new.
Basics
| Field | Value |
|---|---|
| Homepage URL | anything; your own instance's URL is fine |
| Callback URL | http://localhost:3001/api/auth/callback/github |
| Setup URL | http://localhost:3001/api/github/installations/callback |
| Redirect on update | enabled |
| Request user authorization (OAuth) during installation | disabled — see below |
| Webhook → Active | disabled — nothing consumes webhooks |
| Where can this be installed | Only on this account, unless you run an instance others sign in to |
Neither path is configurable: they are where the app listens. For a deployed instance
use that host instead of localhost:3001; you can register up to ten redirect URIs,
so add both.
Why the two URLs are different, and why OAuth-during-installation is off
The app has two return paths because signing in and granting repository access are two different things:
- Callback URL is the OAuth redirect. Signing in starts in Terrablox, so the
auth library generates a
state, remembers it, and checks it when GitHub comes back. That is what makes the redirect tamper-proof. - Setup URL is where GitHub returns after an install, a permission change, or a
request sent to an organisation owner. GitHub starts these, not Terrablox, so
there is no
state— and none is needed: nothing is granted by the redirect, it only reports what GitHub already did.
Leave Request user authorization (OAuth) during installation enabled and GitHub
sends the post-install redirect to the Callback URL instead, carrying an
installation_id and no state. The auth library looks the state up, finds nothing,
and answers:
/api/auth/error?error=state_not_found
That is a correct answer to the question it was asked, and a dead end for whoever just granted access — most visibly when requesting the app on an organisation, where the redirect is the only feedback the flow has. Turning the option off costs nothing: whoever installs the app is already signed in, so there is no second authorisation to collect.
Tick Redirect on update so a changed selection returns the same way rather than leaving the user on GitHub.
Permissions
Every one of these is load-bearing. Grant less and the failure arrives later, in the
middle of a deployment, as a 404 that reads like a deleted repository.
| Permission | Level | Why |
|---|---|---|
| Repository → Metadata | Read | repository details, branches |
| Repository → Contents | Read and write | read Terraform, commit the graph |
| Repository → Workflows | Read and write | commits touching .github/workflows/ are refused without it, on top of Contents |
| Repository → Actions | Read and write | start a deployment run and follow it |
| Repository → Variables | Read and write | the pipeline's configuration |
Leave everything else off, including Administration, Secrets and every account permission. Terrablox never creates, deletes or reconfigures a repository, and the pipeline's configuration lives in repository Variables because none of it is secret. Without Email addresses, an account that keeps its email private on GitHub is shown with its GitHub noreply address. In particular not Copilot Chat: it sounds relevant because the agent runs on Copilot, but it is the permission for Copilot Extensions, which receive a user's chat messages. Terrablox calls Copilot rather than being called by it, and enabling it would only add a data-sharing line to the consent screen.
There are no scopes to configure. GitHub Apps use permissions instead, and the
scope parameter is ignored — which is why the app requests none.
Afterwards
You end up with a client ID and a client secret, which pnpm quickstart prompts for
and writes into apps/dashboard/.env. To add them later, put them in that file as
GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET and restart the dev server. If you
registered your own app rather than using github.com/apps/terrablox, also set
GITHUB_APP_SLUG to your app's slug so the in-app links point at it.
Then install the app on the account whose repositories you want to use. Signing in and installing are two separate things: signing in identifies you, installing is what grants access to repositories. Terrablox links to the install page wherever a repository list is empty, and on the account screen under Repository access.
Choose Only select repositories — it is supported and recommended. Terrablox does
not create repositories: a project is imported from one that already exists. To start
a project, create an empty repository on GitHub with a README (the first commit is what
Terrablox commits on top of), add it to the installation's selection, and pick it in
Import project. The project is named after the repository. Only repositories without any .tf file are accepted, so the
Terraform root is always the repository root and there is nothing to configure.
Module libraries
Terrablox keeps module libraries in sync: sets of Terraform module repositories that users build projects from, next to the modules they import themselves. Every module shows which library it comes from, and the modules page can filter by library.
All libraries are configured in one JSON array in .env, TERRABLOX_MODULE_LIBRARIES
(one line). Unset, it holds the two defaults:
TERRABLOX_MODULE_LIBRARIES=[{"id":"terrablox","name":"Terrablox","scope":"global","visible":true,"org":"terrablox","prefix":"terrablox-aws-","submoduleContainer":"modules"},{"id":"porsche","name":"Porsche","scope":"user","visible":true,"org":"porsche-code","topics":["aws","cbbac"]}]
| Field | |
|---|---|
id |
Slug stored on every row, e.g. terrablox. Required. |
name |
Label shown on every module. Required. |
scope |
global or user (see below). Required. |
visible |
false hides the library everywhere without touching its rows. Default true. |
org |
GitHub org or user. Required. |
prefix, topics |
Repository name prefix and/or topics, all of which must match. At least one is required. |
publicOnly |
Global only: list public repositories only. Default true. |
submoduleContainer |
Pinned submodule folder, e.g. modules. Default: detected. |
pipelineSecret |
Repository secret generated pipelines read for private modules. Default TERRABLOX_<ID>_MODULES_TOKEN. |
Adding an entry adds a library without any code change. [] means no libraries. An
invalid entry is skipped and logged. If the JSON is invalid as a whole, only the
public Terrablox entry is used.
There are two kinds:
- Global (
"scope": "global"): one shared copy that every user sees, such as the Terrablox catalogue of AWS modules. An operator imports it with a token from the shell. - Per-user (
"scope": "user"): private repositories, such as Porsche. Each user's copy is read with that user's own GitHub token (see below).
Importing the Terrablox catalogue takes a GitHub token and a few minutes, so the quickstart asks first. To import it later:
GITHUB_TOKEN=$(gh auth token) pnpm db:builtin:sync # = db:library:sync --library terrablox
GITHUB_TOKEN=$(gh auth token) pnpm db:library:sync # every global library
pnpm db:library:status # rows and visibility per library
db:library:sync re-imports every ref by default, which also refreshes the
analysis.
--changed-onlyskips tags that are already imported. The default branch is always refreshed.--pruneremoves repositories the org no longer lists. Pruning is opt-in, because removing a shared module also removes it from every user's canvas.
Container startup never imports anything: a sync takes minutes and needs a token.
Visibility is separate from importing. "visible": false on an entry hides that
library from every list, picker, agent and API, without deleting any rows. Switching
it back on is instant, and a hidden library can still be imported in advance. While it's hidden, or before it has
been imported, a project whose Terraform calls one of its modules shows that call as a
node with no inputs or outputs.
Private module libraries (Porsche)
Next to the Terrablox catalogue, Terrablox offers per-user libraries: the repositories
in one GitHub organisation that carry all of a set of topics. The default is
Porsche: repositories in porsche-code tagged both aws and cbbac (archived
repositories are skipped). Each module shows its library as a badge, and the modules
page can filter by library.
These repositories are private, so they are never part of the shared catalogue. Each user's copy is discovered with that user's own GitHub token when they open the modules page or the canvas picker. It refreshes at most every 15 minutes, or at once with Refresh. A user who can't read the repositories doesn't get the library and sees no error. When access is revoked or a topic is removed, the module disappears at the next sync.
To make the library available:
- An owner of
porsche-codeinstalls the Terrablox GitHub App on the organisation (https://github.com/apps/<GITHUB_APP_SLUG>/installations/new) and adds the module repositories to the selection. The app needs no extra permissions. - Users need read access to those repositories on GitHub.
If the app is installed but grants no matching repositories, the modules page links to the installation settings. If it isn't installed and GitHub reveals that you're a member of the org, the page links to the install page. GitHub only reveals public memberships to the app. With a private membership you get no hint, so make the membership public or ask an owner.
A per-user library is an entry with "scope": "user" in TERRABLOX_MODULE_LIBRARIES.
To hide it without touching anyone's rows, set "visible": false on it.
Pipelines that use Porsche modules
A workflow's GITHUB_TOKEN can only read its own repository, so terraform init
cannot clone porsche-code modules with it. When a project's Terraform calls one, the
generated plan and apply workflows get an extra step before terraform init. That
step reads a repository secret and sets an org-scoped git auth header. The deploy
wizard lists the secret under Private module access. To create it:
- Create a fine-grained personal access token (or a machine user's token).
- Resource owner:
porsche-code. - Repository access: only the module repositories.
- Repository permissions: Contents: Read-only.
- Resource owner:
- Add it in the project repository under Settings → Secrets and variables →
Actions as
TERRABLOX_PORSCHE_MODULES_TOKEN. The name isTERRABLOX_<ID>_MODULES_TOKEN, orpipelineSecretif the library sets one.
Terrablox never reads or writes secrets. Projects without Porsche modules get unchanged workflows and need no secret.
Captured agent turns
Every agent turn that changes a project is recorded whole, as training data for the harness. The record holds the prompt and the settings it ran under, the project graph before and after, the trajectory of thoughts and tool calls, the mutations and decisions, and the commits the turn produced. A later turn backfills the pipeline's verdict on those commits — whether the resulting Terraform was valid — which is the label that makes a turn worth learning from.
Not every turn starts from the graph. Three starting states are distinguished:
- greenfield — the project was empty; the agent built from nothing.
- graph — the agent extended the existing Terraform graph.
- legacy — the agent read an external source and migrated from it. The source is captured in full: the application-repository files the agent read (as it saw them, pinned to a commit) and the MCP calls it made, including the arguments and the result each call returned. This is the on-prem or legacy state a migration transforms from, which the before/after graph alone cannot show.
Turns that only answer a question commit nothing and are not recorded. The data is kept unredacted: it may carry account ids, ARNs, bucket names and source code from the systems it describes, and users are told their projects are used this way.
Export the records as JSONL, one turn per line:
pnpm transformations:export > transformations.jsonl
pnpm transformations:export --labelled-only > labelled.jsonl # only turns a pipeline has ruled on
pnpm transformations:export --kind legacy > migrations.jsonl # only migrations from a legacy source
Everyday commands
pnpm dev # both apps in watch mode
pnpm build # production build
pnpm type-check # tsc across the workspace
pnpm check # format and lint with Biome
pnpm db:up # start PostgreSQL
pnpm db:down # stop it
pnpm db:migrate # apply migrations
pnpm db:studio # browse the database
What next
Open the dashboard and start a project. The pages that covered configuration,
self-hosting and the underlying concepts are being rewritten; until they are back,
the environment variables are documented in .env.docker.example and the
docker-compose.yml at the repository root.