buidl

module
v0.1.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: MIT

README

buidl

Build container images without a Docker daemon and deploy them to Kubernetes as immutable, digest-pinned releases.

buidl init
buidl deploy

That is the happy path. plan, promote, rollback, and destroy are there when you need them. Staging is implied when -e is omitted; production is never implied. destroy always requires -e.

plan and deploy can also install k3s or RKE2 on machines you already have. Creating those machines is not buidl's job. Use OpenTofu, Terraform, Ansible, or a cloud console, then list the hosts in buidl.yaml.

Install

curl -fsSL https://raw.githubusercontent.com/danecwalker/buidl/main/install.sh | bash

That detects the platform, shows download progress, and verifies the SHA-256 against checksums.txt from the same GitHub release. The binary lands in /usr/local/bin when you can write there, otherwise ~/.local/bin. Later upgrades are buidl update.

linux and darwin, amd64 and arm64. The binaries are static (CGO_ENABLED=0) and built with -trimpath.

With Go 1.25+:

go install github.com/danecwalker/buidl/cmd/buidl@latest

go install does not stamp the version, so the binary reports dev. Use the installer or make install if you need buidl --version to match a tag. buidl update will replace a dev binary with the latest release.

From source:

git clone https://github.com/danecwalker/buidl.git
cd buidl
make build      # ./bin/buidl
make install    # $GOPATH/bin/buidl, version stamped from git describe

buidl --version reports the release tag on published binaries and the git describe string on a make install build.

Quick start

Needs a kubeconfig and a BuildKit endpoint (see Requirements).

cd my-app
buidl init --registry ghcr.io/myorg
# set proxy.host, infra.servers, and certManagerEmail if you need them
buidl add --database postgres   # optional
buidl deploy

init detects Go, Node, Python, Ruby, Rust, and static sites. If there is no Dockerfile it writes a multi-stage one. It also scaffolds .buidl/ (secrets + hooks) and a GitHub Actions workflow.

How a release works

  1. BuildKit builds the image and pushes it straight to the registry. Nothing is stored in a local Docker image store, so there is no separate docker push.
  2. The deploy pins that image by digest. A pod restart cannot pick up different bytes.
  3. promote ships an existing digest to another environment. It does not rebuild.
  4. Release history lives in the cluster, so releases and rollback work from any machine.

plan dry-runs against the API server, so the diff uses the same defaulting and admission a real apply would. --detailed-exitcode returns 2 when something would change (cluster or app), which is how a pipeline can require approval only when needed.

Configuration

A minimal file is enough to deploy:

app: web
image: ghcr.io/acme/web

That gives you a HorizontalPodAutoscaler (CPU 70%, bounds from the fleet or a 1–4 fallback), port 8080, a /up readiness probe, a rolling update with maxUnavailable: 0, a non-root pod with all capabilities dropped, and a namespace named after the app. Set replicas to pin a static count. Preview environments stay at one replica.

A more complete file:

version: 1
app: web
image: ghcr.io/acme/web

build:
  driver: buildkit          # or prebuilt, to deploy an image that already exists
  dockerfile: Dockerfile
  platforms: [linux/amd64]
  cache: registry           # survives ephemeral CI runners
  secrets:
    npm_token: env:NPM_TOKEN   # BuildKit mount, never written into a layer

deploy:
  target: kubernetes
  port: 3000
  healthcheck:
    path: /up
  resources:
    requests: {cpu: 100m, memory: 128Mi}
    limits: {memory: 512Mi}
  strategy:
    type: rolling           # rolling | bluegreen | recreate
    maxUnavailable: "0"
  drainTimeout: 30s
  deployTimeout: 5m

env:
  clear:
    LOG_LEVEL: info
  secret: [DATABASE_URL]

proxy:
  host: acme.com
  ssl: true                 # cert-manager issues the certificate

environments:
  staging:
    proxy: {host: staging.acme.com}

  production:
    deploy:
      autoscale: {min: 5, max: 20}   # optional; omit to size from the fleet

  preview:
    deploy:
      kubernetes:
        namespace: web-preview-${BUIDL_SLUG}
        createNamespace: true
        ephemeral: true
    proxy:
      host: ${BUIDL_SLUG}.preview.acme.com

Environments deep-merge onto the base. Maps merge key by key. Sequences are replaced, so platforms: [linux/arm64] in an overlay means exactly that list.

buidl environment list
buidl environment new qa --host qa.example.com
buidl environment set staging

environment (alias env) edits the overlays. It does not create or destroy cluster objects.

Variables

${VAR}, ${VAR:-default}, and ${VAR:?why it is needed} are expanded after the YAML is parsed, so a value with YAML metacharacters cannot break the file structure.

buidl sets:

Variable Value
BUIDL_ENV selected environment
BUIDL_SHA / BUIDL_SHORT_SHA commit sha
BUIDL_BRANCH branch (recovered from CI on detached HEAD)
BUIDL_SLUG DNS-safe branch slug, or pr-<n> in CI
BUIDL_GIT_TAG exact-match git tag, if any
BUIDL_PR pull request number in CI
BUIDL_CI CI provider, when detected
BUIDL_VERSION buidl version

BUIDL_SLUG is how preview environments get a hostname and namespace without extra config.

A preview environment is disposable. buidl destroy -e preview deletes its namespace. The generated GitHub workflow does that when the pull request closes (merge or not). Staging is a separate deploy from main; the preview objects are never moved into staging.

destroy --stale 7d is the backstop for a missed close event: it deletes preview namespaces older than the duration. Long-lived environments (staging, production) keep their namespace and any accessories; only the app objects are removed, and production also requires --force.

Secrets

env.secret lists names only. Values are resolved at deploy time, lowest precedence first:

Source Committed Notes
.env, .env.<environment> usually only when env.dotenv: true
.buidl/secrets-common yes indirections only: DATABASE_URL=$PROD_DATABASE_URL
.buidl/secrets no all environments
.buidl/secrets.<environment> no one environment
process environment n/a always wins

The process environment outranks every file, so CI injection is never overridden by a leftover file on a laptop. A $VAR value looks up the environment. An unresolved indirection is treated as missing, not deployed as the literal string $VAR.

Values go into a Kubernetes Secret, never into buidl.yaml. A checksum of the resolved secrets is annotated on the pod template, so changing a secret value rolls the pods.

buidl variable list (or var / vars) prints each variable, its kind, and where it came from. Secrets show as set, N chars, never the value.

For secrets already in the cluster (External Secrets, Vault), use env.secretRefs.

env.dotenv: true reads .env and .env.<environment> for declared names only. A stray local variable cannot become part of a release. .env.local and .env.<environment>.local are never read.

buidl init adds .buidl/secrets and .buidl/secrets.* to .gitignore. It does not ignore .buidl/, because secrets-common and hooks are meant to be committed.

.buidl/
  secrets-common          committed, indirections only
  secrets                 gitignored
  secrets.production      gitignored
  hooks/
    pre-deploy            executable
    pre-deploy.sample     inert until you chmod +x
Private registries

buidl needs credentials to push. The cluster needs its own credentials to pull. Your local docker login does not reach the kubelet.

registry:
  createPullSecret: true
  # or reference a secret you already manage:
  # pullSecret: my-registry-creds

Push credentials come from the standard Docker config (docker login, gcloud auth configure-docker, docker/login-action). createPullSecret copies that credential into the cluster. It is opt-in because that is a trust decision.

Accessories

Databases, caches, and queues sit next to the app in the same file. The usual way to add one is a command, not an edit:

buidl add --database postgres
buidl add --database redis

That writes type: postgres (or redis) and generates POSTGRES_PASSWORD plus DATABASE_URL into .buidl/secrets. Image, port, volume and mount path are filled at load. A first buidl deploy creates any accessory that is not already in the cluster. Later deploys leave existing accessories alone — including ones that have drifted — so shipping a web change cannot restart a database.

accessories:
  postgres:
    type: postgres
    # image, port, storage, and POSTGRES_PASSWORD are defaulted

An explicit image / storage still wins. Untyped accessories with a full spec keep working.

Each accessory becomes a StatefulSet plus a headless Service. Inside the namespace, postgres resolves as <app>-postgres.

buidl accessory plan       # what would change, and what that would restart
buidl accessory apply      # reconcile; prompts before anything that restarts a pod

Accessory images are not digest-pinned (buidl did not build them). They are never deleted: removing one from buidl.yaml stops managing it. You delete the StatefulSet and volume yourself.

Accessories have unit tests and render through the real Kubernetes scheme. They have not been applied to a real cluster yet. Treat the first one as an experiment.

Hooks

Executables in .buidl/hooks. Each one gets the release identity and every resolved secret.

Hook When Failure aborts the deploy?
pre-build before the image is built yes
post-build after the image is pushed (BUIDL_DIGEST set) yes
pre-deploy after preflight, before apply yes
post-deploy after the release is healthy no
deploy-failed after a failed deploy no

pre-deploy is the usual place for migrations: the image exists, nothing is serving yet, and the hook can use a credential the app never sees.

Hooks also get BUIDL_APP, BUIDL_ENV, BUIDL_RELEASE, BUIDL_DIGEST, BUIDL_IMAGE, BUIDL_NAMESPACE, BUIDL_GIT_SHA, BUIDL_GIT_BRANCH, BUIDL_ACTOR, BUIDL_URL, BUIDL_VERSION, and BUIDL_HOOK=1. A hook that exists but is not executable is reported, not skipped. buidl hooks lists what is enabled.

Clusters

buidl never creates VMs, networks, firewalls, or load balancers. Once the machines exist, list them:

infra:
  ssh:
    user: root              # keyPath optional; ssh-agent is used by default
  kubernetes:
    distribution: k3s       # or rke2
    version: v1.33.5+k3s1   # pin anything you intend to keep
    controlPlaneEndpoint: k8s.acme.com   # required for more than one control plane
    disable: [traefik]
  addons:
    buildkit: true
    certManagerEmail: ops@acme.com   # enables cert-manager when proxy.ssl is set
    # metricsServer is turned on automatically when deploy.autoscale is set
    # and the distribution does not already bundle it (k3s does).
  servers:
    - {host: 203.0.113.1, role: control-plane, privateIP: 10.0.0.1}
    - {host: 203.0.113.2, role: control-plane, privateIP: 10.0.0.2}
    - {host: 203.0.113.3, role: control-plane, privateIP: 10.0.0.3}
    - {host: 203.0.113.10, role: worker, labels: {pool: web}}
    - {host: 203.0.113.11, role: worker, labels: {pool: gpu}, taints: ["gpu=true:NoSchedule"]}

There is no separate "create the cluster" command. buidl plan inspects the servers and includes any Kubernetes install they need. buidl deploy converges the cluster, then ships the app.

buidl plan    -e production
buidl deploy  -e production

Use deploy --skip-cluster against a cluster you manage yourself.

The cluster commands are for inspection and teardown:

Command What it does
cluster inventory resolved fleet, and which machine bootstraps
cluster status node health, including machines that never joined
cluster kubeconfig fetch credentials and merge into ~/.kube/config
cluster reset uninstall Kubernetes. Leaves the servers running. Type the environment name, or pass --yes.
Dual-stack (IPv4 and IPv6)

New clusters are dual-stack. The defaults are:

infra:
  kubernetes:
    clusterCIDR: 10.42.0.0/16,fd00:42::/56
    serviceCIDR: 10.43.0.0/16,fd00:43::/112

Pod and service IPv6 is ULA (fd00::/8), so it does not consume the host's public /64. k3s also gets flannel-ipv6-masq, which SNATs pod IPv6 to the node's public address. RKE2 gets the same CIDRs and does not get the flannel key.

This is required for public TLS. Let's Encrypt prefers IPv6 whenever a name has an AAAA. An IPv4-only ingress on a host with a public AAAA fails HTTP-01 and Traefik keeps serving its default certificate.

DNS and the cloud firewall have to match:

  • A record: the server's IPv4
  • AAAA record: the server's IPv6 (on Hetzner, 2a01:...::1 from the assigned /64, not the /64 itself)
  • 80/443 open from 0.0.0.0/0 and ::/0

privateIP still pins node-ip to the private IPv4. If the host also has a global IPv6, buidl appends it so ServiceLB publishes both families.

Opt out by setting both CIDRs to IPv4-only. Mixed families (IPv6 pods, IPv4-only services) are rejected.

An existing IPv4-only cluster cannot pick this up in place. Rewriting cluster-cidr on a running node crash-loops flannel. Rebuild it:

buidl cluster reset -e production    # type the environment name, or pass --yes
buidl deploy -e production

cluster reset uninstalls Kubernetes and leaves the machines running. The next deploy bootstraps a new dual-stack cluster.

Only infra.provider: static is implemented. Addresses go in buidl.yaml. The inventory interface exists so a later provider can read tofu output -json or an Ansible inventory without changing the cluster code.

OpenTofu owns the machines

examples/hello/infra is a complete split: OpenTofu creates a Vultr VM, firewall, and SSH key. buidl never calls a cloud API.

cd examples/hello/infra
tofu init
tofu apply
tofu output -raw ipv4
# put that address in examples/hello/buidl.vultr.yaml, point DNS at it
buidl deploy -e vultr -f examples/hello/buidl.vultr.yaml

examples/hello/infra/ha does the same for three control-plane nodes. HA join ordering (one control plane at a time) and buidl promote are implemented. That HA example has not been run against real machines.

Cluster behaviour
  • The first control-plane server initializes the cluster. Extra control planes join one at a time so two etcd members never join at once. Workers join in parallel.
  • Re-runs converge. A healthy node whose rendered config matches is left alone (up-to-date). Other actions are bootstrap, join-control-plane, join-worker, reconfigure, upgrade, or skipped.
  • If no server can be reached, that is an error, not "no changes".
  • Two control planes is worse than one (twice the failure surface, still zero fault tolerance). buidl warns. More than one control plane without controlPlaneEndpoint is an error.
  • Single-node clusters still use embedded etcd, so they can grow later.
  • privateIP sets node-ip so cluster traffic stays on the private network. If the host also has a global IPv6, it is appended so ServiceLB can publish both families.
  • New clusters are dual-stack. See Dual-stack. Existing IPv4-only clusters need cluster reset then deploy, not a live CIDR rewrite.
  • Host keys are checked by default. Unknown keys fail with instructions to run ssh-keyscan, or set infra.ssh.acceptNewHostKeys: true for trust-on-first-use. A mismatched key always fails.
  • The join token is never printed, is written 0600, and is generated with crypto/rand when you do not supply one.
  • Remote commands are single-quote escaped. File content is streamed over stdin, not pasted into echo.
  • In-cluster BuildKit (addons.buildkit: true) is rootless. Point builds at it with build.addr: tcp://buildkitd.buidl-system:1234.
  • After converging, deploys use the kubeconfig context buidl created (<app>-<environment>), not whatever happens to be current.
  • A version upgrade re-runs the installer in place and restarts the unit, one control plane at a time. It does not cordon or drain first. For production, drain the nodes yourself or accept the restart.

Plan and deploy output

buidl plan reports each object, the fields that change, and the runtime effect:

    environment  production
    release      a1b2c3d-tjnz3d
    image        sha256:bbbbbbbbbbbb

       KIND        NAME  CHANGES                                              EFFECT
    ~  Deployment  web   image: sha256:aaaa -> sha256:bbbb, replicas: 3 -> 5  replaces 5 instances
    +  Ingress     web                                                        publishes externally
       Service     web

    plan: 1 to create, 1 to update, 1 unchanged
Effect Meaning
replaces N instances a pod-template field changed (image, env, resources, port, secrets)
scales to N only the replica count changed
no restart inert edit (metadata, strategy)
switches live traffic Service selector flip (blue-green cutover)
publishes externally a new Ingress

Unchanged objects are listed too. --detailed adds the full YAML diff. Secret changes are reported by key name only (values changed: DATABASE_URL). Values never appear in plan output.

buidl deploy ends with what changed, what is running, and where the time went. If apply fails partway through, it names what already landed and says to re-run (apply is idempotent). Terminal pod states (ImagePullBackOff, CrashLoopBackOff, missing Secret) are caught in seconds, with the failing container's logs, instead of waiting out the deploy timeout.

-o json emits newline-delimited events. Every line decodes into the same Event type. Release ID, digest, and URL are also exported as CI step outputs.

Commands

Command Purpose
init detect the project, write buidl.yaml, Dockerfile, CI
build build and push an image, print the digest
deploy converge the cluster if needed, then build, push, apply, wait
plan dry-run the cluster and the app
promote deploy one environment's exact digest to another
rollback previous release, or --to <id>
destroy tear down an environment (preview namespace, or app objects)
status live release, health, instances
releases history from cluster revisions
logs stream logs (-F to follow)
manifest print the YAML buidl would apply
config show / validate / environments inspect resolved config
environment / env list new set delete manage environment overlays
variable / var list set delete inspect and set release variables
add --database / --service write a database or this app's host into the file
hooks which lifecycle hooks are enabled
accessory plan / apply reconcile databases and caches
cluster ... inspect or tear down a buidl-managed cluster

Global flags: -e/--env, -f/--config, -o/--output {auto,pretty,plain,json}, -v/--verbose, --no-color, --timeout.

Useful deploy flags: --auto-rollback, --skip-cluster, --skip-build --digest sha256:..., --allow-dirty, --yes.

Useful destroy flags: --yes, --dry-run, --stale 7d, --force (production).

Exit codes: 0 success, 1 failure, 2 changes detected (plan --detailed-exitcode), 3 invalid configuration.

CI

Output is plain in CI, colored on a terminal, or newline-delimited JSON with -o json. Warnings and errors become CI annotations. A dirty working tree warns locally and fails in CI.

buidl init writes a workflow in this shape:

  • Pull request: preview environment plus a production plan
  • Pull request closed: buidl destroy -e preview deletes the preview namespace
  • Merge to main: deploy staging
  • Manual dispatch: promote staging's digest to production, gated on a GitHub environment

Requirements

  • A BuildKit endpoint, unless build.driver: prebuilt. Discovery order: build.addr, $BUILDKIT_HOST, a rootless socket, /run/buildkit/buildkitd.sock, then a Docker/Podman/nerdctl container named buildkitd (or any running moby/buildkit, including a Buildx builder). If none of those exist, buidl creates moby/buildkit:v0.25.1 as buildkitd.
  • Kubernetes 1.30+, for server-side apply and preStop.sleep.
  • cert-manager if proxy.ssl: true. Set infra.addons.certManagerEmail and buidl installs it.
  • An ingress controller if proxy.host is set.
  • metrics-server if deploy.autoscale is set. buidl installs it on RKE2, or when k3s's bundled copy has been disabled.
  • For public TLS with an AAAA record: 80 and 443 reachable on IPv6. Let's Encrypt validates over IPv6 first.

Writes use server-side apply with field manager buidl. Per app and environment that is a ServiceAccount, Deployment, Service, and optionally Secret, Ingress, HorizontalPodAutoscaler, PodDisruptionBudget, Namespace. Objects get app.kubernetes.io/* labels and buidl.dev/* annotations (release, digest, commit, actor, timestamp).

Examples

All under examples/hello: a small Go app with failure switches (FAIL_READINESS, CRASH_ON_BOOT, BOOT_DELAY) so you can prove the unhappy path.

File What it is for
buidl.yaml existing local cluster. Used by the acceptance suite.
buidl.vm.yaml two machines over SSH. Installs k3s, joins a worker, deploys.
buidl.vultr.yaml one public server, Ingress, cert-manager, TLS.
buidl.ha.yaml three control planes and a staging-to-production promote.
infra/ OpenTofu that creates the Vultr machines those last two configs expect.

Against an existing cluster:

export DEMO_SECRET=any-value-for-testing
buidl deploy -e local -f examples/hello/buidl.yaml
FAIL_READINESS=1 buidl deploy -e local -f examples/hello/buidl.yaml --auto-rollback

Against two VMs you already have, after putting their addresses in the file:

buidl plan   -e vm -f examples/hello/buidl.vm.yaml
buidl deploy -e vm -f examples/hello/buidl.vm.yaml

Status

The Kubernetes deploy backend and the k3s installer have been run against real clusters and real machines. The acceptance suite (test/acceptance/run.sh) covers build, push, apply, health gating, crash-loop detection, auto-rollback, hooks, pull secrets, and the inspection commands.

Not yet run against real infrastructure:

  • RKE2
  • version upgrades on a live cluster
  • cluster reset
  • blue-green cutover
  • accessories
  • multi-arch builds
  • the three-node HA example and promote on that topology

Not implemented: inventory providers other than static, and deploy.target values other than kubernetes.

Development

make test        # unit tests. No cluster, no network.
make lint
make build
make acceptance  # real cluster and registry
export DEMO_SECRET=any-value-for-testing
make acceptance
KEEP=1 ./test/acceptance/run.sh healthy

See CONTRIBUTING.md.

License

MIT

Directories

Path Synopsis
cmd
buidl command
Command buidl builds and deploys applications to Kubernetes, cloud, and bare metal.
Command buidl builds and deploys applications to Kubernetes, cloud, and bare metal.
internal
build
Package build produces container images and resolves them to immutable digests.
Package build produces container images and resolves them to immutable digests.
cli
Package cli wires the command surface.
Package cli wires the command surface.
cluster
Package cluster turns a fleet of servers into a Kubernetes cluster.
Package cluster turns a fleet of servers into a Kubernetes cluster.
config
Package config defines the buidl.yaml schema and the rules for resolving it into a concrete, per-environment deployment spec.
Package config defines the buidl.yaml schema and the rules for resolving it into a concrete, per-environment deployment spec.
deploy
Package deploy defines the backend-agnostic contract for shipping a release.
Package deploy defines the backend-agnostic contract for shipping a release.
deploy/kubernetes
Package kubernetes implements the deploy backend for Kubernetes clusters.
Package kubernetes implements the deploy backend for Kubernetes clusters.
gitinfo
Package gitinfo reads repository provenance for a deploy.
Package gitinfo reads repository provenance for a deploy.
hooks
Package hooks runs user-supplied executables at points in the deploy lifecycle.
Package hooks runs user-supplied executables at points in the deploy lifecycle.
inventory
Package inventory models the fleet of machines a cluster is built from.
Package inventory models the fleet of machines a cluster is built from.
project
Package project infers how to build and run an application from the files in its directory.
Package project infers how to build and run an application from the files in its directory.
release
Package release defines the identity of a single immutable deployment.
Package release defines the identity of a single immutable deployment.
remote
Package remote executes commands on servers over SSH.
Package remote executes commands on servers over SSH.
secrets
Package secrets resolves the values for names declared in env.secret.
Package secrets resolves the values for names declared in env.secret.
ui
Package ui renders progress and results for three very different consumers: a human at an interactive terminal, a CI log, and a machine parsing JSON.
Package ui renders progress and results for three very different consumers: a human at an interactive terminal, a CI log, and a machine parsing JSON.
update
Package update finds and installs buidl releases from GitHub.
Package update finds and installs buidl releases from GitHub.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL