@gambi97/keel-cli
v0.5.0
Published
One command from zero to a production-shaped serverless infrastructure on Scaleway (Terraform + GitHub Actions + Infisical). Only Node required.
Downloads
1,617
Maintainers
Readme
A ship's keel is the first beam laid down, the backbone everything else is
built onto. keel lays that foundation for whatever you are building: a
complete serverless infrastructure on Scaleway,
provisioned by Terraform, deployed by GitHub Actions, with secrets in
Infisical. It does not generate an app: you bring a
Docker image, keel gives it a place to run. You answer a few questions, push
to main, and the infrastructure is live.
keel is a starting point, not a control plane. It runs once to lay the foundation and hand you a repository you fully own. From then on, day-2 work — scaling, new environments, custom domains, rotating secrets — happens in that repository: edit a tfvars line, open a PR, merge. keel does not stay in the loop, has nothing to update, and never manages your infrastructure after the first setup. If you outgrow the generated layout, it is plain Terraform you can take anywhere.
- Near-free to start. Compute and database scale to zero; an idle project costs cents per month, with no expiring trial.
- Scales with your product. Pick your environments (production only, or add staging and dev) and, if you need it, an application file store; growing is a one-line tfvars change reviewed in a PR.
- Nothing to install. No Terraform, no cloud CLI. Node and git, done.
- No secrets in the repo, ever. Encrypted CI secrets and a dedicated secret manager, wired for you.
- No lock-in of your code. Your app is a plain Docker image on a port.
Quickstart
You need Node >= 18.17, git, and credentials for Scaleway, Infisical and GitHub (see Prerequisites for how to get each one).
npx @gambi97/keel-cliThe CLI asks for a project name, region and environments, then walks one
provider at a time — GitHub, Infisical, Scaleway — verifying each set of
credentials with read-only calls the moment you enter it (a bad token or a
non-empty repository is reported immediately, and only that answer is asked
again). It picks up SCW_* / INFISICAL_* / GITHUB_TOKEN from your
environment as defaults, then shows a complete summary of what it will
create and where. Nothing is touched before you confirm. When it finishes, push to main (or merge the
first PR) and the pipeline provisions the non-production infrastructure; a
version tag (vX.Y.Z) promotes to production.
Non-interactive and dry-run:
# scripts / CI: every question has a flag
npx @gambi97/keel-cli --yes --name my-app --region fr-par --private
# preview: generates the repo locally, touches no account
npx @gambi97/keel-cli --dry-run --yes --name my-appSee the full CLI reference for all flags, or pass
--config answers.json.
Why keel
Getting anything real online means gluing together a cloud account, a container runtime, a database, a state store, a CI/CD pipeline and a secret manager, then threading credentials through all of them without leaking anything. It is a day of undifferentiated setup before the first line of product.
keel collapses that into one command, guided by three opinions:
1. Start near zero, grow without re-architecting. Both compute (Serverless Containers) and database (Serverless SQL) bill per use and scale to zero. The same repo that costs cents while you validate the idea carries real traffic later by bumping two numbers in a tfvars file. See the cost breakdown.
2. The repository is the single source of truth, and it holds zero secrets. All infrastructure is Terraform reviewed in PRs; nothing is click-configured. Terraform runs in CI, never on a laptop. Credentials live in GitHub's encrypted store (CI) and Infisical (application), so rotating a secret never requires a commit.
3. Infrastructure only, no framework. keel generates no application skeleton. Your app is any Docker image, listening on a port, reading its config from environment variables. Nothing to rewrite if you outgrow the stack.
Why Scaleway, and not AWS or the others
AWS can absolutely do this, but the equivalent setup on AWS means assembling Lambda + API Gateway + Aurora + ECR + S3 + a thick layer of IAM, each with its own knobs. keel optimizes for a different thing: the shortest path from nothing to running, cheap, scalable infrastructure.
- Truly scale-to-zero, on both tiers. Scaleway Serverless Containers and Serverless SQL drop to zero when idle. On AWS, a serverless database typically keeps minimum billable capacity running; for a project that is quiet most of the time, that difference is the whole point.
- No free tier clock. Pricing starts near zero and stays per-second; nothing expires after 12 months.
- A handful of resources instead of a sprawl. Less to assemble, less IAM surface to misconfigure.
- Standard containers. A plain Docker image, portable by default.
- EU-based, with straightforward pricing and data residency.
The trade-off is honest: Scaleway has a smaller catalog than the hyperscalers. If you need a specific managed AWS service, this is not for you. If you want a serverless product online today for near nothing, it fits.
Why Infisical for secrets
Secrets are the easiest thing to get wrong: a DATABASE_URL committed "just
for now", credentials copy-pasted across consoles until nobody knows the
source of truth.
- One home for application secrets, separate from code and CI credentials, with clean staging/prod separation.
- Rotate without a deploy. Change the value, the next apply picks it up.
- Read from both sides. Terraform reads at plan/apply, the container reads at runtime, same place.
- Open source and self-hostable. Point keel at your own instance.
CI credentials (the Scaleway and Infisical keys themselves) stay in GitHub Actions encrypted secrets, because that is what CI needs to boot.
How it works
flowchart TD
You([You: npx @gambi97/keel-cli]) -->|APIs, after you confirm| CLI{{CLI bootstrap}}
CLI -->|creates state bucket| SCW[(Scaleway<br/>Object Storage)]
CLI -->|creates repo, secrets,<br/>variables, branch rules| GH[GitHub repo]
CLI -->|creates project, envs,<br/>placeholder secrets| INF[Infisical]
GH -->|merge to main| ACT[GitHub Actions]
GH -->|tag vX.Y.Z| ACT
ACT -->|apply non-prod / prod| PROD[Scaleway:<br/>Container + SQL + Registry]
INF -.->|app secrets at runtime| PRODThe CLI does the bootstrap, in seconds, on your machine. The first
terraform apply runs in CI on the first push to main, so nobody needs
local tooling or production credentials. Non-production environments deploy on
every merge; production is promoted by pushing a version tag.
What gets created, and when
Phase A: bootstrap (the CLI, via APIs, after your confirmation)
| Where | What |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Your machine | The generated repo: Terraform, workflows, README, initial git commit |
| Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking, restricted by a bucket policy to the identity behind your API key) |
| GitHub | Repository (public or private) pushed to main; encrypted Actions secrets; Actions variables; one deployment environment per selected environment (production promoted by a version tag); branch protection on main |
| Infisical | A project with one environment per selected environment, seeded with BASIC_AUTH_USER / BASIC_AUTH_PASSWORD (non-production, random password), DATABASE_URL / APP_URL placeholders per environment, and S3_* placeholders when Object Storage is enabled |
Phase B: first deploy (Terraform in GitHub Actions, on push to main)
| Scaleway resource | Notes |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Registry namespace | Private, one per environment |
| Container namespace + Serverless Container | The container starts on keel's placeholder page and switches to your app when you set container_image in the tfvars |
| Serverless SQL Database | One per environment; after each apply the pipeline writes a ready-to-use connection string to Infisical as DATABASE_URL |
| IAM application + API key | Dedicated credential that can only read/write the database (least privilege), embedded in DATABASE_URL |
| Object Storage bucket + credential (opt-in) | Only with --object-storage: one bucket per environment plus a dedicated least-privilege credential; coordinates synced to Infisical as S3_* |
No custom domain is configured: the app gets an auto-generated Scaleway URL.
Add one later with a single scaleway_container_domain resource.
The generated repository
my-app/
├── README.md # operating manual for the repo
├── .github/workflows/
│ ├── terraform-plan.yml # PR: fmt + validate + plan (every environment)
│ ├── terraform-apply.yml # merge to main: apply non-prod; tag vX.Y.Z: apply prod
│ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
├── .keel/manifest.json # committed record: keel version, contract version, options
├── versions.tf · providers.tf · backend.tf
├── backend.hcl.example # state bucket coordinates (backend.hcl is git-ignored)
├── variables.tf · main.tf · outputs.tf
├── <env>.tfvars # one per environment, non-sensitive config, committed
└── modules/app_stack/ # registry + container + database (+ object storage) + credentialsEnvironments are separated with Terraform workspaces: same code, one
independent state per environment in one bucket, differences confined to the
per-environment <env>.tfvars files.
| Data | Lives in | Why |
| ----------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform |
| Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply |
| Basic Auth user/password | Infisical (non-prod) | App secret, injected into the container, rotatable |
| Database connection string | Infisical (each env) | Complete, ready-to-use value synced by the pipeline after each apply |
| App public URL (APP_URL) | Infisical (each env) | Synced by the pipeline after each apply (real from the first one — the container starts on keel's placeholder page); for links, OAuth callbacks, webhooks |
| Object Storage coordinates (opt-in) | Infisical (each env) | S3_* values synced by the pipeline after each apply |
| Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
| Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
After the bootstrap
The generated repository ships its own README: the full operating manual for day-2 work (scaling, rotating secrets, custom domains, troubleshooting). The short version of the first deploy:
- Push to
main(or merge a PR): the pipeline provisions registry and databases for the non-production environments (and an Object Storage bucket, if enabled). Production waits for a version tag (step 5). - Build and push your app image to the registry endpoint from the apply
output (replace
<env>with your target environment):docker login rg.fr-par.scw.cloud/my-app-<env> -u nologin --password-stdin <<< "$SCW_SECRET_KEY" docker build -t rg.fr-par.scw.cloud/my-app-<env>/app:latest . docker push rg.fr-par.scw.cloud/my-app-<env>/app:latest - Set
container_imagein the tfvars and open a PR: the next apply replaces the placeholder page with your app. - Replace the placeholder secrets in Infisical with real values. The app
reads
DATABASE_URL,APP_URL,BASIC_AUTH_*and (if enabled)S3_*from its environment; non-production environments also receiveBASIC_AUTH_ENABLED=trueand enforce it. - Release to production: push a version tag —
git tag v1.0.0 && git push --tags— and the production apply runs against that commit. A merge tomainnever deploys production.
Prerequisites
Three accounts and their credentials. Nothing is installed locally: the CLI talks to each service through its API and validates every credential before creating anything.
- Create an account and a project at console.scaleway.com.
- Generate an API key (IAM > API keys > Generate): you need the access key and the secret key.
- Note your project ID and organization ID (Project Dashboard).
- The key needs to create Object Storage buckets and manage Serverless
Containers, Serverless SQL and the Registry (
ObjectStorageFullAccess,ContainersFullAccess,ServerlessSQLDatabaseFullAccess,ContainerRegistryFullAccess, plusIAMManagerso Terraform can create the app's dedicated least-privilege database credential). - Organization security settings — API-key expiration must be unlimited (Console → Organization → Security → API keys). The app's database and Object Storage credentials are non-expiring service credentials by design (the container reads them at runtime); if your organization forces API keys to expire, the first CI apply fails creating them. keel checks this setting during validation and warns before anything is created.
- Create an account at app.infisical.com (or use a self-hosted instance).
- Create a Machine Identity with Universal Auth: you need its client ID and client secret. Create the client secret with unlimited uses (Max Number of Uses = 0): CI logs in with it on every plan/apply, so any finite usage limit eventually runs out mid-pipeline.
- Give the identity permission to create and manage projects.
- Create a token at github.com/settings/tokens
with the
repoandworkflowscopes (classic), or a fine-grained token allowed to create repos and manage Actions secrets, variables, environments and branch protection.
Security model
- No secret ever lands in the repository: not in Terraform, not in
tfvars, not in workflows.
backend.hcland local state are git-ignored. - The CLI never logs credentials and redacts them in the summary. The GitHub
token is handed to
git pushthrough an ephemeral askpass helper, so it never appears in remote URLs,.git/configor the process list. - Actions secrets are encrypted client-side (libsodium sealed box) before upload.
mainis protected: force pushes and deletion blocked, PRs need a green plan. Production never deploys from a merge — it is gated behind a version tag, a free promotion control that works on any GitHub plan (required-reviewer rules need a paid plan on private repos) and lives auditably in the repo.- Terraform state lives in a private, versioned bucket with S3-native state
locking (
use_lockfile), so concurrent applies cannot corrupt it. - The state bucket is restricted by a bucket policy to the identity that
owns the API key you give keel — the same identity CI authenticates with,
so the pipeline keeps working. Terraform state contains the generated
credentials (
DATABASE_URL,S3_*), so no other principal in the project — including the app's own credentials — can read it. Note that this also hides the bucket from other console users; manage or remove the policy with that same API key if you need to open it up. - The app connects to its database with a dedicated least-privilege IAM credential (read/write on that database, nothing else), not with your main API key. Object Storage, when enabled, gets its own separate dedicated credential that can only use Object Storage (and, thanks to the bucket policy above, cannot read the Terraform state).
- A weekly drift-detection plan opens an issue when the real infrastructure no longer matches the code.
What it costs to start
keel is tuned to sit near the free tier while you have little or no
traffic. A rough monthly picture for a minimal setup, staging and prod
included (Scaleway fr-par list prices, excl. VAT):
| Component | Minimal setup | Monthly cost |
| --------------------------------- | -------------------------- | ------------------------------------------ |
| Serverless Containers (both envs) | scale-to-zero, low traffic | ~€0 (200k vCPU-s + 400k GB-s free / month) |
| Serverless SQL (both envs) | idle most of the time | ~€0.20 storage + a few cents of compute |
| Object Storage (Terraform state) | a few MB | ~€0 |
| Container Registry | 1-2 image versions | ~€0.05 (€0 if the registry is public) |
| Object Storage (optional) | only if --object-storage | ~€0 idle (a few GB of storage when used) |
| Total to start | | under ~€1 / month |
Compute is billed per second, only while actually serving: an idle container
and a paused database drop to zero and you pay a few cents of storage. Cost
grows with real usage, not with the number of environments. When traffic
arrives, raise min_scale / max_scale in the tfvars and you move from
"near free" to paying for the capacity you actually use, with no
re-architecting.
Failure recovery
Every bootstrap step checks whether its resource already exists, and progress
is recorded in .keel.json inside the project directory. If a run fails
halfway, fix the cause and re-run the same command: completed steps are
skipped, existing resources are reused, nothing is duplicated.
Tearing it down
keel teardown deletes everything a project created on Scaleway and
Infisical, so the name can be reused for a fresh run — no console clicking:
npx @gambi97/keel-cli teardown --name my-app # asks to type the name back
npx @gambi97/keel-cli teardown --name my-app --dry-run # print the plan, delete nothing
npx @gambi97/keel-cli teardown --name my-app --yes # non-interactiveIt removes, where present: the per-environment Scaleway resources (container and registry namespaces, databases, IAM applications/policies, Object Storage buckets) for every environment keel could have created, the Terraform state bucket, and the Infisical project. Resources are found by keel's exact naming convention inside your Scaleway project, missing ones are skipped, and a partially-failed teardown can simply be re-run.
The GitHub repository is never touched — it is the source of truth and
may hold your own commits, the one thing a re-run cannot recreate. Deleting it
stays a manual act: repository Settings → Danger Zone. Teardown therefore
needs no GitHub token at all; it uses the same Scaleway and Infisical
credentials as the bootstrap (pass the same --region / --infisical-host
the project was created with — interactive runs ask). Your credentials
themselves and the local directory are never touched.
FAQ
Do I need Terraform, scw or gh installed?
No. The CLI bootstraps via APIs; Terraform runs inside GitHub Actions.
Can the repository be private?
Yes. The CLI asks for the name and the visibility; default is public (the
infra holds no secrets), or choose private interactively or with --private.
Can I point keel at an existing repository? Only if it has no commits. keel pushes a brand-new history, so a repository that already has commits (even just a README from the GitHub UI) would reject the push; the CLI checks this up front and asks for another name. The easiest path is to let keel create the repository for you.
Can I reuse an existing Infisical project?
Yes: pass its project ID (--infisical-project-id or interactively). The CLI
verifies the machine identity can access it before anything is created. Leave
it empty and keel creates a project named after your app instead.
What does the container run before I push my image?
keel's placeholder page, a tiny image that renders the project name and
environment, so the very first apply brings a real URL up. The apply workflow
copies it into the environment's own registry on the first run — the container
never depends on an external registry. Replace it with your app's image when
ready, or set container_image = "" to skip the container entirely.
Why Basic Auth "at the app level"?
Scaleway Serverless Containers have no built-in auth in front of public
endpoints. Credentials live in Infisical, the container gets
BASIC_AUTH_ENABLED=true, and a few lines of middleware enforce it.
Which environments do I get, and can I change them?
You choose at creation: production only, staging + production (default), or
dev + staging + production — interactively or with --environments
(e.g. --environments prod or --environments dev,staging,prod). Non-production
environments deploy on merge to main and enable Basic Auth by default;
production is promoted by pushing a version tag (vX.Y.Z), never by a merge. To
add a non-production environment later: add a <env>.tfvars (the plan and drift
workflows discover environments from the tfvars files at the repo root
automatically), an Infisical environment with the same slug, and a main-gated
job in terraform-apply.yml.
Can I extend the generated repo with my own Terraform?
Yes, and additions never edit generated files: drop a new .tf file at the
repo root (Terraform merges all root files), put per-env values in the
existing tfvars, and expose an output named infisical_secrets_<name> if your
module produces secrets the app should read — the pipeline collects every
output matching that prefix and syncs it to Infisical automatically. The
committed .keel/manifest.json records the contract version your repo was
generated with.
Can I store files, not just rows?
Yes, opt in with --object-storage (or answer yes interactively). Each
environment gets its own Object Storage bucket and a dedicated credential; the
pipeline syncs the S3_* coordinates to Infisical for the app to read. It is
off by default — many apps only need the database.
CLI reference
npx @gambi97/keel-cli [options] create and bootstrap a project
npx @gambi97/keel-cli teardown [options] delete a project's Scaleway/Infisical resources
--name <name> Project name (dns-safe: lowercase, digits, hyphens)
--dir <path> Target directory (default: ./<name>)
--region <region> fr-par | nl-ams | pl-waw (default: fr-par)
--scw-access-key <key> or env SCW_ACCESS_KEY
--scw-secret-key <key> or env SCW_SECRET_KEY
--scw-project-id <id> or env SCW_DEFAULT_PROJECT_ID
--scw-organization-id <id> or env SCW_DEFAULT_ORGANIZATION_ID
--infisical-host <url> or env INFISICAL_HOST (default: https://app.infisical.com)
--infisical-client-id <id> or env INFISICAL_CLIENT_ID
--infisical-client-secret <s> or env INFISICAL_CLIENT_SECRET
--infisical-project-id <id> Existing Infisical project ID to reuse
(or env INFISICAL_PROJECT_ID; default: create by name)
--infisical-project-name <n> Infisical project name (default: project name)
--github-token <token> or env GITHUB_TOKEN / GH_TOKEN (scopes: repo, workflow)
--repo-name <name> GitHub repository name (default: project name)
--private / --public Repository visibility (default: public)
--environments <preset> prod | staging+prod | dev+staging+prod
(or a list like "dev,staging,prod"; default staging+prod)
--object-storage Provision a per-environment Object Storage bucket
--no-object-storage Do not provision Object Storage (default)
--basic-auth Enable Basic Auth on non-production environments (default)
--no-basic-auth Disable Basic Auth on non-production environments
--container-size <size> Per-instance resources: 100m | 250m | 500m | 1000m
(mvCPU; default 500m = 500 mvCPU / 1024 MB)
--dev-min-scale <n> Default 0 --dev-max-scale <n> Default 1
--staging-min-scale <n> Default 0 --staging-max-scale <n> Default 1
--prod-min-scale <n> Default 0 --prod-max-scale <n> Default 1
--config <file.json> Load answers from a JSON file
--advanced Also ask scaling questions interactively
--yes Accept defaults, skip the confirmation prompt
--dry-run Generate locally, touch no accountA --config file uses the same nested shape as the flags; anything missing
is taken from the environment or asked interactively:
{
"projectName": "my-app",
"region": "fr-par",
"environments": ["staging", "prod"],
"objectStorage": true,
"github": { "repoPrivate": true },
"scaling": { "prod": { "maxScale": 4 } }
}Development
npm install
npm run build # tsc -> dist/
npm test # vitest unit tests
npm run lint # eslint
npm run verify:templates # render templates + terraform fmt/validate (needs terraform)
node dist/index.js --dry-run --yes --name demo # end-to-end without accountsReleasing (maintainers)
Publishing runs in CI, never from a laptop:
npm version minor # bumps package.json, commits and tags vX.Y.Z
git push --follow-tags # the Release workflow publishes to npm with provenanceThe workflow refuses a tag that does not match package.json and skips
versions that are already on the registry, so re-running it is always safe.
