# Wake Sleep

> Add a wake-on-visit / auto-stop-when-idle (hibernation) mechanism to an AWS deployment to cut cost. Generates drop-in Terraform + Python Lambda wake/ autosleep functions + a manual CLI, assembled from composable adapters for the target's compute target, idle signal, and in-flight-work probe. Use when the user wants to: stop an idle EC2 / ECS / Auto Scaling Group / RDS and auto-start it on traffic or on a schedule; add a "serverless-ish" sleep mode to an always-on single box; save cloud spend by shutting down non-prod compute off-hours; hibernate / auto-start-stop / schedule a server.

- Skill: `colinzyang/wake-sleep` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add colinzyang/wake-sleep`
- Raw SKILL.md: https://api.skillmd.com/api/skills/colinzyang/wake-sleep/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: colinzyang (https://skillmd.com/u/colinzyang)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/colinzyang/wake-sleep

---


# wake-sleep

Turn an always-on AWS resource into a **pay-only-when-used** one: it starts on
traffic (or on a schedule), serves, and stops itself after a proven-idle
window. Zero new always-on spend by design — it reuses infra the project
already has (an ALB, or a cheap cron), and the two Lambdas sit in the free tier
at low traffic.

This is a **kit of parts**, not one hardcoded stack. Read the target project,
pick adapters, assemble.

## The contract (what's generic vs. pluggable)

The orchestration is fixed and correct-by-construction; only the
cloud-specific bits swap:

| Seam | Options (pick one per axis) |
|---|---|
| **compute driver** | `ec2_instance` · `ec2_asg` · `ecs_fargate` · `ecs_ec2` · `rds` · `composite` |
| **wake trigger** | `alb` (reuse existing ALB) · `apigw` (no ALB) · `schedule` (office hours) · `manual` (CLI only) |
| **idle signal** | `cw_requestcount` (ALB reqs) · `cw_metric` (any CloudWatch metric) · `http` (a probe URL) · `none` (schedule-only sleep) |
| **busy probe** | `ssm` (shell cmd on the box) · `http` (200 = busy) · `none` |

Everything else is universal and ships unchanged every time (see
[design-decisions.md](references/design-decisions.md) for the why):

- **Ground-truth health check** — never trust load-balancer target health (it
  lags `unhealthy_threshold × interval` after a stop); the wake Lambda hits a
  configurable `HEALTH_URL` that is routed *directly* to the target, bypassing
  the wake path, to avoid recursion.
- **Recursion-safe routing** — when the trigger is an ALB, listener rules are
  ordered so `/` → wake Lambda → health check → EC2 can't loop.
- **Reload cap** — a forgotten tab during a slow boot can't pin the box awake.
- **Fail-safe-busy** — any error in the busy probe is treated as "busy"; we
  never stop on uncertain signal.
- **Min-uptime guard** + **idempotent start/stop** + **DRY_RUN default true**.

## Workflow

### 1. Interview the target (infer from repo, confirm with user)

Use `AskUserQuestion` to lock these. Many have a sensible default you can
propose first:

1. **Compute target** — what gets slept? (single EC2 / ASG / ECS service / RDS /
   a group). Find how it's currently defined (Terraform? Console? ECS cluster
   name?).
2. **Existing public ingress?** — is there an ALB the trigger can reuse
   (cheapest, zero new always-on spend)? If not, offer API Gateway or
   schedule-only.
3. **Idle signal** — default `cw_requestcount` if there's an ALB; else
   `cw_metric` (e.g. CPU) or `http`.
4. **In-flight work** — does the target run async jobs? If yes, pick a busy
   probe and pick the exact command from [job-probe-recipes.md](references/job-probe-recipes.md)
   (Celery / RQ / BullMQ / Sidekiq / k8s Job / DB-row / none).
5. **Schedule window?** — office-hours wake (don't start at 3am even if
   poked) and/or schedule-only mode. Optional.
6. **Notify?** — SNS/Slack on wake/sleep. Optional.

### 2. Generate the lambda bundle (always)

Copy all of `assets/lambda/` (wake.py, autosleep.py, compute_drivers.py,
signals.py, state.py) into the project verbatim — they are generic and
env-driven. Only the *env vars* differ per deployment (set in Terraform).

### 3. Generate Terraform (compose fragments)

Always: `assets/terraform/variables.tf` + `wake-sleep-core.tf` (IAM roles
scoped to the chosen driver, both Lambdas sharing one zip, autosleep
EventBridge `rate(5 minutes)`).

Then add **one** wake trigger:
- ALB present → `wake-alb.tf` (listener rules with the recursion-safe priority
  ordering + `/_health` bypass). **This is the zero-new-spend path.**
- No ALB, want on-demand wake → `wake-apigw.tf`
- Schedule only → `wake-schedule.tf`

Optional: `notify-sns.tf`.

**Fill the resource references** for the project's integration mode:
- *Same Terraform state* (ALB/instance managed here): replace `var.*` IDs with
  direct resource refs (`aws_instance.app.id`, `aws_lb.public.arn`, etc.).
- *External* (managed elsewhere): pass IDs via variables or `data` sources.
Both are shown as comments in `wake-sleep-core.tf`.

### 4. Adapt the busy probe

Drop the project-specific busy-probe command into `BUSY_PROBE_CMD` (Terraform
env var). See `references/job-probe-recipes.md`. If `none`, autosleep skips the
probe.

### 5. Wire ground-truth health

Ensure `HEALTH_URL` resolves to a 200 from the target, served *through the wake
trigger but routed directly to the target* (not back into the wake Lambda).
- ALB trigger: add a high-priority listener rule forwarding `HEALTH_PATH`
  (default `/_health`) straight to the target group. The fragment does this.
- Else: point `HEALTH_URL` at the target's own health endpoint.

### 6. Ship DRY_RUN, validate, flip

Generate autosleep with `AUTOSLEEP_DRY_RUN = true`. Tell the user:
1. `terraform apply`
2. Watch CloudWatch Logs for `autosleep` — confirm it logs "keep alive" /
   "would stop" with the right reasoning for a few cycles.
3. Flip `AUTOSLEEP_DRY_RUN = false`, `apply` again.

### 7. Hand off

- **Wake URL** to bookmark (ALB: `http://<alb>/<wake_path>`; APIGW: the
  endpoint; schedule: "starts at HH:MM").
- **sleepctl** — copy `assets/sleepctl.sh`, set `DRIVER` + resource IDs +
  region, `chmod +x`. `status | up | down [--force]`.
- **Cost note**: compute bills per-second while running, ~$0 while stopped;
  only storage + ingress baseline keep accruing.

## Minimum-viable fast path

The 80% case — single EC2 behind an existing ALB, with an async worker — is
just the MAAP reference shape: driver `ec2_instance`, trigger `alb`, idle
`cw_requestcount`, busy `ssm`. Reach for that first; only deviate when the
interview says so.

## Reference index

- [porting-guide.md](references/porting-guide.md) — decision tree: which driver/signal/trigger per stack, with copy-paste configs.
- [job-probe-recipes.md](references/job-probe-recipes.md) — busy-probe one-liners per task system.
- [design-decisions.md](references/design-decisions.md) — the why behind every default (read before deviating).
- [generated-layout.md](references/generated-layout.md) — what the generated layout looks like and how to wire it.

## Iron rules (don't regress)

- **Never weaken fail-safety**: probe errors ⇒ busy; never stop on uncertain signal.
- **Never ship autosleep without DRY_RUN first.**
- **Never trust LB target health as the wake-ready signal.**
- **Keep data traffic off the wake Lambda** — only the wake path goes through it; app traffic flows target-direct. (ALB trigger: the default listener action and all non-wake rules forward to the target, not the Lambda.)
- **IAM least-privilege per driver** — don't grant `ec2:*` when the driver only needs ECS, etc.

