{"id":50525534,"url":"https://github.com/miller-joe/vaultwake","last_synced_at":"2026-06-03T07:31:22.408Z","repository":{"id":357092892,"uuid":"1235153972","full_name":"miller-joe/vaultwake","owner":"miller-joe","description":"One-click HashiCorp Vault unseal + auto-restart of dependent Docker stacks. Self-hosted homelab utility.","archived":false,"fork":false,"pushed_at":"2026-05-11T09:12:47.000Z","size":44,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-11T10:40:29.091Z","etag":null,"topics":["devops","docker","docker-compose","hashicorp-vault","homelab","nodejs","self-hosted","selfhosted","typescript","unseal","vault","vault-agent"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","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/miller-joe.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-05-11T04:04:36.000Z","updated_at":"2026-05-11T09:13:48.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/miller-joe/vaultwake","commit_stats":null,"previous_names":["miller-joe/vaultwake"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/miller-joe/vaultwake","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/miller-joe%2Fvaultwake","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/miller-joe%2Fvaultwake/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/miller-joe%2Fvaultwake/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/miller-joe%2Fvaultwake/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/miller-joe","download_url":"https://codeload.github.com/miller-joe/vaultwake/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/miller-joe%2Fvaultwake/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33853984,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-03T02:00:06.370Z","response_time":59,"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":["devops","docker","docker-compose","hashicorp-vault","homelab","nodejs","self-hosted","selfhosted","typescript","unseal","vault","vault-agent"],"created_at":"2026-06-03T07:31:17.696Z","updated_at":"2026-06-03T07:31:22.403Z","avatar_url":"https://github.com/miller-joe.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cimg src=\"assets/logo.svg\" width=\"80\" height=\"80\" alt=\"vaultwake\"\u003e\n\n# vaultwake\n\nOne-click [HashiCorp Vault](https://www.vaultproject.io/) unseal + auto-restart of dependent Docker stacks. Built for self-hosted homelabs where Vault gates secrets that other services need at boot.\n\n## What it does\n\nAfter a server restart, Vault comes up sealed. Anything that depends on a vault-agent sidecar can't fetch its secrets and either crashes or stalls. **vaultwake** is a single-page web app that:\n\n1. Shows Vault's seal status.\n2. Takes your unseal key in a single field.\n3. Calls Vault's API to unseal.\n4. Discovers every Docker Compose stack that has a `vault-agent` service — automatically, no list to maintain.\n5. Restarts those stacks with a bounded-concurrency `down` → wait → `up -d`, retries failures, verifies each stack is actually running afterwards, and streams live logs to the browser.\n6. Persists every run to `$DATA_DIR/logs/\u003crun-id\u003e/` so you can read what happened the next day. Past runs are browsable in the UI and via `/api/runs`.\n7. Lets you opt specific stacks out via a checklist that persists to disk.\n\nBy default vaultwake passes `--pull never --no-build` so it never re-downloads or rebuilds images — image management is left to your update pipeline (e.g. bumpsight). Set `COMPOSE_PULL=missing` / `COMPOSE_BUILD=true` if you want different behavior.\n\n## Why not a hardcoded list\n\nMost homelab \"restart everything that needs Vault\" scripts (mine included) are a hardcoded array of service names. They drift the moment you add a new vault-using stack. vaultwake walks `compose.yaml` files at request time and picks up anything with a `vault-agent:` service. Add a stack — it appears. Remove one — it disappears.\n\n## Run it\n\n```yaml\n# compose.yaml\nservices:\n  vaultwake:\n    image: ghcr.io/miller-joe/vaultwake:latest\n    container_name: vaultwake\n    restart: unless-stopped\n    ports:\n      - \"8210:3000\"\n    environment:\n      VAULT_ADDR: http://vault:8200       # how vaultwake reaches Vault\n      STACKS_DIR: /stacks                  # where compose files live (mounted)\n      DATA_DIR: /data                      # persistent skip-list\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n      - /path/to/your/compose/stacks:/stacks:ro\n      - /path/to/persist/vaultwake:/data\n    networks:\n      - vault_default                       # join Vault's docker network\nnetworks:\n  vault_default:\n    external: true\n```\n\nThen `docker compose up -d` and visit `http://\u003chost\u003e:8210`.\n\n## Config\n\n| Env var | Default | What |\n| --- | --- | --- |\n| `PORT` | `3000` | Listen port inside the container |\n| `VAULT_ADDR` | `http://vault:8200` | Base URL of the Vault API |\n| `STACKS_DIR` | `/stacks` | Where to scan for `*/compose.yaml` |\n| `DATA_DIR` | `/data` | Where the skip-list and run logs live |\n| `STACK_CONCURRENCY` | `4` | How many stacks to `down`/`up` in parallel. Set lower if dockerd is saturating. |\n| `STACK_TIMEOUT_MS` | `180000` | Per-stack subprocess timeout. SIGTERM at the limit, SIGKILL 5s later. |\n| `STACK_RETRIES` | `1` | Retries per stack on failure (not counting the first attempt). |\n| `STACK_RETRY_BACKOFF_MS` | `5000` | Sleep between retry attempts. |\n| `WAIT_BETWEEN_MS` | `3000` | Pause after the stop phase before starting. |\n| `COMPOSE_PULL` | `never` | `--pull` flag for `compose up`: `never` / `missing` / `always`. |\n| `COMPOSE_BUILD` | `false` | If true, allow `compose up` to auto-build. Default passes `--no-build`. |\n| `LOG_RETENTION` | `50` | How many past runs to keep on disk (older runs are pruned after each run). |\n\n## Run logs\n\nEvery restart writes a directory under `$DATA_DIR/logs/\u003crun-id\u003e/`:\n\n```\n20260511-094523-x1y2/\n├── meta.json          # options, started/ended, per-stack succeeded/failed/retried\n├── combined.log       # every SSE event in chronological order (JSONL)\n├── gitea.log          # raw docker-compose output for this stack\n├── seafile.log\n└── ...\n```\n\nYou can read them from the \"Past runs\" panel in the UI, or via the API:\n\n| Endpoint | What |\n| --- | --- |\n| `GET /api/runs?limit=20` | List recent runs with their `meta.json` (newest first). |\n| `GET /api/runs/:id` | One run's `meta.json`. |\n| `GET /api/runs/:id/log` | Combined JSONL stream for the run. |\n| `GET /api/runs/:id/log/:stack` | Per-stack plain-text log. |\n\n## How stack health is verified\n\nAfter `compose up -d` exits 0, vaultwake runs `compose ps --format json` for that stack and only marks it OK if every service is in state `running`. This catches the common failure mode where `up -d` returns success but a service exits immediately (e.g. a `vault-agent`-rendered env var didn't make it into the entrypoint). Stacks that fail the post-up check are retried per `STACK_RETRIES` and reported with their failing service in the run summary.\n\n---\n\n## Setting up vault-protected stacks (recommended pattern)\n\nvaultwake is only useful if your stacks actually use Vault for their secrets. Here's the canonical sidecar pattern that vaultwake auto-discovers — it's how every stack in the author's homelab works, and we recommend it for new self-hosters.\n\n### One-time Vault setup\n\nAssuming you have Vault already running with a KV v2 mount at `secrets/`:\n\n```bash\n# 1. Enable AppRole auth (one time, server-wide)\nvault auth enable approle\n\n# 2. Per-stack: write a policy granting read on this stack's secret path\nvault policy write ghost - \u003c\u003cEOF\npath \"secrets/data/ghost/*\" {\n  capabilities = [\"read\"]\n}\nEOF\n\n# 3. Per-stack: create an AppRole bound to that policy\nvault write auth/approle/role/ghost \\\n  token_policies=\"ghost\" \\\n  token_ttl=1h \\\n  token_max_ttl=24h \\\n  secret_id_ttl=0      # never expires; rotate manually if you prefer\n\n# 4. Pull the role-id and a fresh secret-id (these go on the host disk)\nvault read -field=role_id auth/approle/role/ghost/role-id      \u003e role-id\nvault write -f -field=secret_id auth/approle/role/ghost/secret-id \u003e secret-id\nchmod 0600 role-id secret-id\n```\n\nMove `role-id` and `secret-id` into `/your/stacks/\u003cname\u003e/vault/config/`.\n\n### Stack file layout\n\n```\n/your/stacks/ghost/\n├── compose.yaml\n└── vault/\n    ├── config/\n    │   ├── agent.hcl       # vault-agent config\n    │   ├── role-id         # 0600\n    │   └── secret-id       # 0600\n    └── templates/\n        └── ghost.env.tmpl  # what gets rendered into a secrets file\n```\n\n### `vault/config/agent.hcl`\n\n```hcl\nvault {\n  address = \"https://vault.example.com\"   # or http://vault:8200 over a docker network\n}\n\nauto_auth {\n  method {\n    type = \"approle\"\n    config = {\n      role_id_file_path   = \"/vault/config/role-id\"\n      secret_id_file_path = \"/vault/config/secret-id\"\n    }\n  }\n  sink {\n    type = \"file\"\n    config = { path = \"/vault/token\" }\n  }\n}\n\n# Render to tmpfs first (avoids permission/atomicity issues on bind mounts),\n# then publish to the shared volume the app reads from.\ntemplate {\n  source      = \"/vault/templates/ghost.env.tmpl\"\n  destination = \"/tmp/ghost.env\"\n  perms       = \"0640\"\n  command     = \"install -m 0640 /tmp/ghost.env /vault/secrets/ghost.env\"\n}\n```\n\n### `vault/templates/ghost.env.tmpl`\n\n\u003e ⚠️ **Always use `export KEY=value`, never bare `KEY=value`.** The app's entrypoint will source this file and then `exec` its real binary as a child process; non-exported variables won't propagate. This is the single most common cause of \"vault is unsealed but my app still crashes.\" Put `export` on **every** line.\n\n```hcl\n{{- with secret \"secrets/data/ghost/database\" }}\nexport MYSQL_ROOT_PASSWORD='{{ .Data.data.MYSQL_ROOT_PASSWORD }}'\nexport MYSQL_PASSWORD='{{ .Data.data.MYSQL_PASSWORD }}'\n{{- end }}\n{{- with secret \"secrets/data/ghost/smtp\" }}\nexport MAIL_USER='{{ .Data.data.USERNAME }}'\nexport MAIL_PASS='{{ .Data.data.PASSWORD }}'\n{{- end }}\n```\n\n### `compose.yaml` for the stack\n\nThe vault-agent sidecar must be a **top-level service named exactly `vault-agent`** — that's what vaultwake greps for during discovery.\n\n```yaml\nservices:\n  vault-agent:\n    image: hashicorp/vault:1.18\n    restart: unless-stopped\n    user: \"0:0\"\n    entrypoint: [\"/bin/vault\"]\n    command: [\"agent\", \"-config=/vault/config/agent.hcl\"]\n    volumes:\n      - ./vault/config:/vault/config:ro\n      - ./vault/templates:/vault/templates:ro\n      - vault-secrets:/vault/secrets\n\n  ghost:\n    image: ghost:6-alpine\n    restart: unless-stopped\n    depends_on:\n      vault-agent:\n        condition: service_started\n    ports:\n      - \"2368:2368\"\n    volumes:\n      - vault-secrets:/vault/secrets:ro\n      - /your/data/ghost:/var/lib/ghost/content\n    entrypoint: [\"/bin/sh\", \"-c\"]\n    command:\n      - |\n        set -eu\n        # Wait for vault-agent to render the secrets file\n        while [ ! -s /vault/secrets/ghost.env ]; do sleep 0.5; done\n        . /vault/secrets/ghost.env\n        # Re-export anything the app expects in env (some apps only read fresh-init)\n        export NODE_ENV=production\n        export url=https://ghost.example.com\n        # Hand off to the image's real entrypoint\n        exec docker-entrypoint.sh node current/index.js\n\nvolumes:\n  vault-secrets:\n```\n\n### Common gotchas\n\n- **`env_file:` doesn't work with these templates.** It parses bare `KEY=value` only and ignores `export`. Always source via `. /vault/secrets/...` inside an inline shell entrypoint.\n- **Postgres-style fresh-init traps.** Postgres reads `POSTGRES_PASSWORD` *only on first init*. If your template ever renders the password without `export`, the bug stays latent until the next fresh init (could be years). Some compose entrypoints belt-and-suspender this with an explicit re-export before `exec docker-entrypoint.sh`.\n- **The `vault-agent` service name is load-bearing.** vaultwake's discovery looks for that exact key. If you call it `vault_agent`, `vault-sidecar`, or `agent`, vaultwake won't see your stack. (You can opt-in via the skip list in reverse if needed, but renaming is simpler.)\n- **Per-stack AppRole, not one shared role.** Sharing one AppRole across stacks means a single compromised app token can read every other app's secrets. The cost of one role per stack is ~30 seconds of `vault write`.\n\n### Adopting the pattern incrementally\n\nYou don't need to migrate everything at once. Convert one stack, verify it boots clean after `docker compose down/up -d`, then add the next. vaultwake will pick up new stacks the moment they have a `vault-agent` service in their compose.\n\n---\n\n## Security model\n\nHonest assessment of what this design protects against and what it doesn't.\n\n### What you get\n\n- **Stolen-disk threat**: Vault's file backend is encrypted with the master key, which is never written to disk. Without an unseal key, an attacker with your drives sees ciphertext for every secret.\n- **Per-app blast radius**: each stack's AppRole can only read its own KV path. A compromised app container yields one set of secrets, not all of them.\n- **Auditable secret access**: turn on Vault's audit log (`vault audit enable file file_path=/vault/logs/audit.log`) and you get a record of every read/write/unseal attempt. vaultwake's unseal calls land here too.\n- **No drift**: dynamic discovery means a stack you forget about won't silently miss a restart and run with stale secrets.\n- **Reverse-proxy auth**: front vaultwake with NPM Access Lists / Authelia / similar so the unseal field isn't on the open LAN.\n\n### What you don't get (and what to do about it)\n\n- **`docker.sock` mount = root on host.** Necessary for `docker compose down/up -d` to work. Same risk profile as portainer/dozzle. Mitigate by treating vaultwake as privileged and keeping it off the public internet.\n- **Browser-typed unseal key.** The key spends a moment in browser memory and travels: browser → reverse proxy → vaultwake → Vault. Use TLS on every hop you can. **Never type your unseal key on a device you don't control.** Store it in a password manager, hardware token, or paper safe — not in shell history, not in a sticky note, not in the same Vault you're trying to unseal.\n- **Secret-id files on disk.** `\u003cstack\u003e/vault/config/secret-id` is plaintext on the host filesystem. Anyone with read access to that file can become that AppRole. Keep them `0600` and consider periodic rotation (`vault write -f auth/approle/role/\u003cname\u003e/secret-id`).\n- **Single-key Shamir is convenient, not strong.** A 1-of-1 unseal means one key holder can unseal alone. Higher-assurance setups use 3-of-5 or 5-of-9 thresholds so no single person can solo-unseal. vaultwake supports the multi-submit flow if you change Vault's threshold — submit each key in turn, Vault tracks progress.\n- **No anomaly detection.** vaultwake doesn't rate-limit unseal attempts itself. Vault does (`max_request_duration`), and the audit log lets you spot brute-force attempts after the fact, but if your reverse-proxy auth is weak, the only thing between an attacker and an unseal attempt is the unseal key itself.\n\n### Is \"manual unseal on every restart\" a best practice?\n\nIt depends on your threat model — there isn't one universally-correct answer.\n\n**HashiCorp's enterprise guidance** leans toward **auto-unseal** via cloud KMS (AWS KMS, GCP KMS, Azure Key Vault) or Vault's own transit secret engine. Auto-unseal means zero downtime on restart, no human in the loop, and HA failover that doesn't require paging someone. The trade-off: your master key now lives wherever the KMS does, and you trust the KMS provider not to read it.\n\n**Manual unseal** (what vaultwake helps with) trades availability for security. The master key never touches the box and never touches a third party. The cost is operational toil — every reboot needs a human, and during the unseal window your secret-consuming services are down.\n\nFor a homelab, manual unseal is the right call when:\n- you don't want to depend on a cloud KMS for an on-prem service\n- restarts are infrequent enough that the toil is acceptable\n- you actually care about the stolen-disk threat (otherwise why use Vault at all?)\n\nFor production, you should think harder. If downtime is unacceptable, auto-unseal with a hardened KMS is probably correct. If your security model genuinely requires a human-in-the-loop, manual unseal — but then plan for HA, on-call rotation, and the operational cost honestly.\n\nvaultwake exists to make the manual path as painless as possible without removing the human. It does not aim to be the answer for every Vault deployment — it aims to be the right answer for the homelab/single-host pattern where you've decided the trade is worth it.\n\n---\n\n## Development\n\n```bash\nnpm install\nnpm run dev\n```\n\nSet `VAULT_ADDR`, `STACKS_DIR`, `DATA_DIR` to point at a real or mock environment.\n\n## License\n\nMIT — see [LICENSE](./LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmiller-joe%2Fvaultwake","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmiller-joe%2Fvaultwake","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmiller-joe%2Fvaultwake/lists"}