{"id":49221614,"url":"https://github.com/thadeu/clowk-voodu","last_synced_at":"2026-05-24T06:05:31.080Z","repository":{"id":353240557,"uuid":"1218546987","full_name":"thadeu/clowk-voodu","owner":"thadeu","description":null,"archived":false,"fork":false,"pushed_at":"2026-05-20T02:21:02.000Z","size":4218,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-20T05:18:22.940Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/thadeu.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-23T01:36:38.000Z","updated_at":"2026-05-20T02:21:05.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/thadeu/clowk-voodu","commit_stats":null,"previous_names":["thadeu/clowk-voodu"],"tags_count":34,"template":false,"template_full_name":null,"purl":"pkg:github/thadeu/clowk-voodu","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thadeu%2Fclowk-voodu","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thadeu%2Fclowk-voodu/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thadeu%2Fclowk-voodu/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thadeu%2Fclowk-voodu/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/thadeu","download_url":"https://codeload.github.com/thadeu/clowk-voodu/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thadeu%2Fclowk-voodu/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33423286,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-23T22:14:44.296Z","status":"online","status_checked_at":"2026-05-24T02:00:06.296Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-04-24T04:01:26.659Z","updated_at":"2026-05-24T06:05:31.064Z","avatar_url":"https://github.com/thadeu.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# voodu\n\n\u003e Self-hosted, commitless-deploy PaaS with first-class stateful services.\n\nVoodu is the evolution of [Gokku](https://github.com/thadeu/gokku). It keeps\nwhat works — single `voodu apply` deploys, blue-green swaps, per-app env\nmanagement — and invests where Gokku is weak: Postgres, Mongo, and other\nstateful services with backup, replica, and test-restore built in, without\nrequiring the plugin sprawl of a full Kubernetes stack.\n\nCommitless by default: edit code, run `voodu apply`, done. The CLI\nstreams the build context straight to the server over SSH — no git\ncommit required, no push, no bare repo.\n\n## Install\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/thadeu/clowk-voodu/main/install | bash\n```\n\nOn a Linux host this is a full **server install**: drops `voodu` and\n`voodu-controller` into `/usr/local/bin`, seeds `/opt/voodu/`, installs\nthe `voodu-controller.service` systemd unit, and starts the daemon on\n`127.0.0.1:8686`. On macOS the same line installs only the CLI\n(**client mode**), for laptops that deploy to remote servers.\n\nForce mode explicitly:\n\n```sh\ncurl -fsSL ...install | bash -s -- --client\ncurl -fsSL ...install | bash -s -- --server\n```\n\nUseful env knobs:\n\n| Var | Default | What it does |\n|---|---|---|\n| `VERSION` | latest release | pin a tag, e.g. `v0.1.0` |\n| `VOODU_ROOT` | `/opt/voodu` | server state directory |\n| `VOODU_HTTP_ADDR` | `127.0.0.1:8686` | controller HTTP bind |\n| `VOODU_INSTALL_REPO` | `thadeu/clowk-voodu` | source repo (for forks) |\n\nPre-built releases for Linux and macOS (amd64/arm64) live on the\n[releases page](https://github.com/thadeu/clowk-voodu/releases).\nRe-running the installer upgrades both binaries and restarts the\ncontroller — it is idempotent.\n\n## Quick start\n\nAfter installing in server mode, `/opt/voodu/` is already seeded and the\ncontroller is running. Create your first app:\n\n```sh\nvoodu apps create prod           # creates /opt/voodu/apps/prod + initial .env\n```\n\nFrom your laptop — declare the app with an HCL manifest:\n\n```hcl\n# voodu.hcl\ndeployment \"prod\" \"api\" {\n  image    = \"ghcr.io/me/api:v1.2\"\n  replicas = 2\n  ports    = [\"8080\"]\n\n  env = {\n    PORT = \"8080\"\n  }\n}\n\ningress \"prod\" \"api\" {\n  host = \"api.example.com\"\n\n  tls {\n    email = \"ops@example.com\"   # enabled + letsencrypt are the defaults\n  }\n}\n```\n\nScoped kinds (`deployment`, `ingress`) take **two labels**: `\u003cscope\u003e` and\n`\u003cname\u003e`. Scope is a free-form organizational tag (app, team,\nenvironment); it groups manifests, selects what prune touches, and is\nthe uniqueness boundary for names. `service` inside `ingress` defaults\nto the ingress name, so the 1-to-1 shape (`deployment \"prod\" \"api\"` ↔\n`ingress \"prod\" \"api\"`) is declaration-only. Port and health_check\ndefault to the deployment's declared port and `/` — set them only when\nyou need to override.\n\nThe `tls {}` block follows the \"block-present = on\" convention: if you\ndeclare it (even empty), `enabled = true` and `provider = \"letsencrypt\"`\nare filled in. To disable TLS for an ingress, omit the entire block.\n\n### Registry mode vs build mode\n\nEvery `deployment` / `statefulset` / `job` / `cronjob` picks one of two\nsource modes (parse error if both are declared on the same resource):\n\n- **`image = \"ghcr.io/me/api:v1.2\"`** — registry mode. Voodu pulls and\n  runs. CI builds and pushes; voodu deploys. Above example uses this.\n- **`build { ... }`** — build mode. CLI streams the working tree to the\n  server as a tarball; the controller runs `docker build` and tags\n  `\u003cscope\u003e-\u003cname\u003e:latest` for the workload to pull.\n\n```hcl\ndeployment \"prod\" \"api\" {\n  replicas = 2\n  ports    = [\"8080\"]\n\n  build {\n    context    = \".\"                # docker build context (default: \".\")\n    dockerfile = \"Dockerfile\"       # default name inside context\n    args = {\n      NODE_VERSION = \"24-alpine\"    # docker --build-arg\n    }\n  }\n}\n```\n\nThe `build {}` block matches docker-compose's shape — `context`,\n`dockerfile`, `args`. Omit the whole block (and `image`) for the terse\n\"ship me from this repo, figure the rest out\" form: voodu auto-detects\nthe runtime from marker files in the working tree (`go.mod`, `Gemfile`,\n`package.json`, …) and generates a Dockerfile if your repo doesn't ship\none. See [`examples/build/`](examples/build/) for monorepo, custom\nDockerfile, and statefulset-build patterns.\n\nThe tarball follows docker-build semantics: `.dockerignore` controls\ninclusion if present, otherwise `.gitignore`. Uncommitted changes ship\n— working tree, not git HEAD.\n\nApply it:\n\n```sh\nvoodu apply -f voodu.hcl\n```\n\n`voodu apply` is the single user-facing entrypoint and the source of\ntruth: the invocation (one file, many `-f`, or a directory) is the\ndesired state. The controller diffs against etcd and **prunes per\n(scope, kind)** automatically — no confirm, no prompt. Applying only\n`deployments.hcl` won't touch ingresses in the same scope, so you can\ndecompose by kind without cross-kind deletion.\n\nPass `--no-prune` to upsert without deletions — see\n[Shared scope across repos](#shared-scope-across-repos) for the\nintended use case.\n\n### File extensions\n\nAll of these are parsed as HCL — pick whichever reads best in your\neditor and file tree:\n\n| Extension | When it's nice |\n|---|---|\n| `.hcl` | Tooling compatibility (most editors / IDEs highlight this by default) |\n| `.voodu` | Branded, reads like a first-class config (`web.voodu`, `api.voodu`) |\n| `.vdu`, `.vd` | Shorter aliases for the same |\n| `.yml`, `.yaml` | YAML variant — same schema, different syntax |\n\n`voodu apply -f web` resolves bare names against all of the above in\norder, so editing `web.voodu` and running `voodu apply -f web` just\nworks.\n\nMore examples live in [`examples/`](examples/):\n\n- [`fullstack/`](examples/fullstack/) — deployment + database + ingress\n- [`build/`](examples/build/) — `build {}` block patterns: auto-detect,\n  custom Dockerfile, Go monorepo, statefulset build\n- [`multi-env/app.voodu`](examples/multi-env/app.voodu) — one manifest,\n  many servers (staging / prod-1 / prod-2 selected with `-r`)\n- [`shared-scope/`](examples/shared-scope/) — one scope fanned out\n  across independent repos with `--no-prune` upsert\n- [`ingress/profiles.hcl`](examples/ingress/profiles.hcl) — four TLS\n  profiles (HTTP, Let's Encrypt, internal CA, on-demand wildcard)\n- [`ingress/paths.hcl`](examples/ingress/paths.hcl) — path-based\n  routing with `location {}` blocks\n\n## Remotes\n\nA **remote** is just an SSH target — a `user@host` pair stored as a git\nremote so every developer clone already knows where the app ships.\nVoodu inherits the git-remote lookup so there's no extra config file.\n\n```sh\n# one-shot bootstrap of a fresh host (ssh preflight + install + server setup)\nvoodu remote setup staging ubuntu@staging.example.com --binary ./bin/voodu\n\n# or just register a remote for an already-provisioned host\nvoodu remote add    prod-1 ubuntu@prod-1.example.com\nvoodu remote add    prod-2 ubuntu@prod-2.example.com\nvoodu remote list\n```\n\nThe HCL manifest owns the app identity (`scope` + `name`). The remote\nowns only the SSH destination. So **one server runs as many apps as the\nHCL declares**, and the same manifest ships unchanged to any server —\nonly `-r` changes:\n\n```sh\nvoodu apply -f voodu.hcl              # default: looks up the \"voodu\" git remote\nvoodu apply -f voodu.hcl -r staging   # ship to staging\nvoodu apply -f voodu.hcl -r prod-1    # ship to prod-1\n```\n\n`-r` is the shorthand for `--remote`. Omit both and voodu uses the git\nremote named `voodu` — handy when a repo targets a single server and\nyou want `voodu apply` to \"just work\".\n\nThree prod hosts behind an AWS ALB? Add `prod-1`, `prod-2`, `prod-3` and\nloop: `for r in prod-1 prod-2 prod-3; do voodu apply -f voodu.hcl -r $r;\ndone`. The scope+name in the manifest stays constant across rollouts.\n\n## Shared scope across repos\n\nBy default every `voodu apply` is a full source-of-truth statement for\nthe `(scope, kind)` pairs it touches — anything the controller knows\nabout in that pair that isn't in this apply gets pruned. That's the\nright default for a single repo that owns its scope: rename a\ndeployment in HCL and the old one disappears, no zombies left behind.\n\nThe shape below is **different**. Four independent repos, one shared\nscope, each applying only its own slice:\n\n```hcl\n# github.com/you/clowk\ndeployment \"clowk\" \"app\" { image = \"ghcr.io/you/clowk:1\" }\n\n# github.com/you/clowk-landingpage\ndeployment \"clowk\" \"lp\"  { image = \"ghcr.io/you/clowk-lp:1\" }\n\n# github.com/you/clowk-api\ndeployment \"clowk\" \"api\" { image = \"ghcr.io/you/clowk-api:1\" }\n\n# github.com/you/clowk-jobs\ndeployment \"clowk\" \"jobs\" { image = \"ghcr.io/you/clowk-jobs:1\" }\n```\n\nWith the default behavior, each `voodu apply` would delete the three\nothers' deployments. Use `--no-prune` to opt into upsert-only:\n\n```sh\nvoodu apply -f voodu.hcl --no-prune\n```\n\nThe flag lives in every CI pipeline that shares a scope, so the choice\nis explicit and grep-able. The default elsewhere stays strict.\n\n**When to reach for this vs. distinct scopes.** The cleaner shape is\nusually one scope per repo (`clowk-app`, `clowk-lp`, `clowk-api`,\n`clowk-jobs`) — ownership is obvious, `voodu list -s clowk-api` scopes\nto one repo, and no pipeline needs a flag. Pick shared scope only when\ngrouping is a first-class concern (a logical environment you want to\nquery and config together) and every apply that touches the scope\npasses `--no-prune`.\n\n## Ingress routing\n\nOne host, many paths, one service:\n\n```hcl\ningress \"acme\" \"api\" {\n  host = \"api.example.com\"\n\n  location { path = \"/api/v1\" }\n  location { path = \"/api/v2\" }\n}\n```\n\nOne host, different services per path (classic versioned API). The\n`/apply` boundary rejects two ingresses claiming the same host **unless**\nthey declare distinct `location {}` blocks — one host, many paths, many\nservices is legal fan-out:\n\n```hcl\ningress \"acme\" \"api-v1\" {\n  host    = \"api.example.com\"\n  service = \"api-v1\"\n  location { path = \"/api/v1\" }\n}\n\ningress \"acme\" \"api-v2\" {\n  host    = \"api.example.com\"\n  service = \"api-v2\"\n  location { path = \"/api/v2\" }\n}\n```\n\n`strip = true` on a location removes the prefix before forwarding — use\nit when routing a generic image (static nginx, arbitrary upstream) that\nexpects root-relative URIs:\n\n```hcl\nlocation {\n  path  = \"/docs/voodu\"\n  strip = true   # backend sees /getting-started, not /docs/voodu/getting-started\n}\n```\n\nOmitting `location {}` entirely is the catch-all for a host.\nEverything inside the app itself (404 pages, rewrites, SPA fallback,\ncompression) stays in your Dockerfile's web server — the platform\nterminates at `host → container:port`.\n\n## Previewing changes with `voodu diff`\n\n`voodu diff` is the \"what would apply do?\" button. It calls the\ncontroller with `?dry_run=true`, so nothing gets persisted and the\noutput reflects **exactly** what the next `voodu apply` with the same\nflags would do — same prune logic, same validation, same ordering.\n\n```sh\n$ voodu diff -f voodu.hcl\n~ deployment/clowk/web\n    ~ image     \"nginx:1.26\"  →  \"nginx:1.27\"\n    ~ replicas  1  →  2\n    + lang.name  \"bun\"\n= ingress/clowk/web (unchanged)\n\n--- Would prune (pass --no-prune to keep) ---\n- deployment/clowk/old-worker\n\n1 to modify, 1 to prune\n```\n\nMarkers:\n- `~ kind/scope/name` — resource exists and its spec changed. Each\n  line underneath is one JSON field that differs, dotted for nested\n  keys (`tls.email`, `lang.name`).\n- `+ kind/scope/name (new)` — resource would be created; field lines\n  underneath are its initial spec.\n- `= kind/scope/name (unchanged)` — spec matches the controller.\n- `--- Would prune ---` — resources that would be removed by the\n  source-of-truth apply contract. Use `--no-prune` to simulate an\n  upsert-only apply (shared-scope case).\n\n### CI-friendly exit codes\n\nPass `--detailed-exitcode` to get `terraform plan`-style exit codes:\n\n| Exit code | Meaning |\n|---|---|\n| 0 | No changes |\n| 1 | Error (couldn't reach controller, invalid manifest, …) |\n| 2 | Plan has pending changes |\n\nLets you wire a `voodu diff --detailed-exitcode` step in CI that\nfails a branch when it drifts from the declared state, or gates a\ndeploy step behind an explicit \"yes there are changes\" signal.\n\n## Configuration\n\nPer-app environment variables are managed out-of-band from the manifest\nso secrets don't live in your repo:\n\n```sh\nvoodu config set DATABASE_URL=postgres://... SECRET_KEY=... -a prod\nvoodu config list   -a prod\nvoodu config get    SECRET_KEY -a prod\nvoodu config unset  OLD_FLAG -a prod\nvoodu config reload -a prod      # recreate the active container\n```\n\nEnv set via `config:set` always wins over `env {}` blocks in the manifest,\nso a `voodu apply` can't accidentally reset a production secret.\n\n## Live resource usage with `voodu stats`\n\n`docker stats` analog scoped to voodu-managed pods, joined with the\nmanifest's `resources.limits` so you see actual usage alongside the\nconfigured ceiling in one shot.\n\n```sh\nvoodu stats                              # every running pod\nvoodu stats clowk-lp                     # one scope\nvoodu stats clowk-lp/web                 # one resource (all replicas)\nvoodu stats deployment                   # all deployments\nvoodu stats -o json | jq '.[] | select(.usage.memory_percent \u003e 80)'\n```\n\nColumns: KIND, REF, CPU%, MEM USED, MEM LIMIT, MEM%, CPU LIMIT. The\ntwo LIMIT columns echo the operator's verbatim manifest strings\n(`254Mi`, `0.4`) — `—` means no `resources {}` was declared. CPU% is\nhost-relative, matching `docker stats` semantics (100% = one full\ncore). Single-shot — wrap in `watch -n 2` for refresh. Pass\n`--orphans` to surface running containers without a matching manifest\n(useful for spotting leaks after a `vd delete` that didn't fully\nclean up).\n\n## How it works\n\n```\nyour laptop                                 server\n───────────                                 ──────\nvoodu apply -f voodu.hcl  ──ssh──▶  voodu-controller\n  │                                         │\n  │                                         └─ reconcile ingress/services (etcd)\n  │  (build-mode only: stream tarball)\n  └─ tar -czf - \u003cbuild.context\u003e  ──ssh──▶  voodu receive-pack \u003cscope\u003e/\u003cname\u003e\n                                             └─ extract → docker build (with --build-arg from build.args)\n                                                → tag \u003cscope\u003e-\u003cname\u003e:latest\n                                                → swap `current` symlink\n                                                → run post_deploy hooks\n                                                → recreate container\n```\n\nTarball transport is content-addressed: an identical tree produces the\nsame build-id (sha256 of the tar bytes) and the server skips the\nrebuild, just repointing `current`. Use `VOODU_FORCE_REBUILD=1` (or\n`voodu receive-pack --force` on the server) to bypass.\n\n- **CLI (`voodu`)** — parses HCL, forwards commands over SSH or to the\n  controller's HTTP API. Installed on laptops and servers both.\n- **Controller (`voodu-controller`)** — long-running daemon backed by an\n  embedded etcd. Owns manifest state, reconciles services, routes unknown\n  commands to plugins.\n- **Plugins** — independent binaries discovered from `/opt/voodu/plugins`.\n  `voodu plugins:install \u003cgithub-repo\u003e` clones and wires them. See\n  [voodu-caddy](https://github.com/thadeu/voodu-caddy) for an example.\n\n## Plugins\n\n| Repo | Purpose |\n|---|---|\n| [`thadeu/voodu-caddy`](https://github.com/thadeu/voodu-caddy) | Ingress (Caddy Admin API, ACME, on-demand wildcard TLS) |\n| [`thadeu/voodu-postgres`](https://github.com/thadeu/voodu-postgres) | Postgres service with backup / replica / test-restore |\n| [`thadeu/voodu-mongo`](https://github.com/thadeu/voodu-mongo) | MongoDB service |\n\nInstall one with:\n\n```sh\nvoodu plugins:install thadeu/voodu-caddy\n```\n\n## Development\n\n```sh\nmake tidy          # download deps\nmake build         # build voodu + voodu-controller into bin/\nmake check         # fmt + vet + lint + test\n./bin/voodu --version\n```\n\nReleases are cut by pushing a `v*` tag — GoReleaser builds cross-platform\nbinaries and publishes them to the GitHub release.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthadeu%2Fclowk-voodu","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthadeu%2Fclowk-voodu","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthadeu%2Fclowk-voodu/lists"}