---
name: opscrew-deploy
description: Take the user's app from this repository to a hardened production deployment on their own cloud, a server they already have, a Portainer they already run (on a NAS or any Docker host), or for a site with no server Cloudflare's hosting or GitHub Pages, with OpsCrew (scan, analyze, interview, design, prepare the code, then provision), and turn Portainer on or off. Use when the user wants to deploy, host, launch or "put online" their app, deploy to their own server, home server, NAS or Portainer, or move it off Lovable, Bolt, Replit or v0 hosting.
---

# Deploy this app with OpsCrew

OpsCrew gives you deterministic, safe tools through its MCP server (`opscrew_*`). You do the
reasoning and talk to the user; OpsCrew does detection, pricing, rendering and anything that touches
infrastructure. Follow these steps in order and keep the user informed in plain English.

## Rules (always)

- Never run `tofu`, `terraform`, `hcloud`, `doctl`, `aws`, `gcloud`, `az`, `ssh` or `curl` yourself
  for this flow. Infrastructure changes happen only through OpsCrew, after the user approves a plan.
- Never ask the user to paste tokens, passwords or `.env` values into this chat, and never print
  them. Secrets are entered in the user's own terminal by `opscrew provision`.
- Do not edit files under `opscrew/`, `.opscrew/` or `.github/workflows/opscrew-deploy*.yml`;
  OpsCrew writes and validates those.
- Tell the user what each step will do and what it costs before doing it.

## Steps

1. **Scan.** Call `opscrew_scan`. Summarise critical and high findings in plain English and offer to
   fix them first (see the `opscrew-scan` skill). Do not continue to deployment while a live secret
   is committed in the repository unless the user explicitly accepts the risk.
2. **Understand the app.** Call `opscrew_detect` and describe the stack back to the user in two or
   three sentences (framework, database, where it came from). Correct obvious misunderstandings by
   asking the user.
3. **Interview.** Call `opscrew_interview_questions`. Ask the user every returned question (use the
   question's options when it has them), then call `opscrew_save_requirements` with the answers.
   One question is where to run the app: Hetzner Cloud, DigitalOcean (Droplets) or a server the
   user already has (`own`: a rented server elsewhere, a company machine or a home server). Its
   default is the provider whose API token is already set (`HCLOUD_TOKEN` or
   `DIGITALOCEAN_TOKEN`), otherwise Hetzner Cloud; let the user choose. For a site that needs no
   server, the question also offers `cloudflare-workers` (Cloudflare's hosting: static files free,
   and server-side rendering for Astro, Next.js, Nuxt and SvelteKit) and `github-pages` (GitHub
   Pages: static only, public sites, no custom headers, no commercial use), only where they fit
   the app (see **Sites with no server** below). For `own`, also ask the
   returned server questions and pass them with the `server.` answer ids they return (address,
   SSH port, user, key, whether the internet reaches the server and how the site is served):
   - the key is a path to the private key file, or `agent` for the SSH agent (which also covers a
     key with a passphrase). Never ask for a password, a passphrase or the key itself.
   - the address stays on the user's computer (`.opscrew/server.json`); tool results say "your
     server" instead. Never write the address into a file, a commit or a pull request.
   - how visitors reach the server, in plain words: (a) through a Cloudflare Tunnel, the default
     behind Cloudflare's proxy and the only way for a server the internet cannot reach (no web
     ports open; needs Cloudflare Zero Trust, free); (b) directly behind Cloudflare's proxy with a
     filter that lets only Cloudflare reach ports 80/443, for a server with a public address (if
     the filter cannot load, Docker and the site stay down rather than open); (c) directly with DNS
     only, open to everyone on 80/443 and without Cloudflare's protection. Recommend (a) unless the
     user has a reason against it. Both Cloudflare ways need a domain on the user's Cloudflare
     account.

   These questions are about what runs next to the app:
   - `workerCommands.<name>`: a background-job library OpsCrew found has no conventional start
     command (BullMQ, asynq, river). Ask the user for the command that starts that worker from the
     app's code (for example `node dist/worker.js`); never guess one.
   - `keepExternalServices.<kind>`: the app already uses a hosted service (a hosted MongoDB,
     PlanetScale, Upstash, Neon and the like). Ask whether to keep it or let OpsCrew run one on the
     server, and pass on what running it there adds (its memory on the server, and its data on the
     server's disk). Without an answer, OpsCrew keeps the hosted one.
   - `keepExternalServices.s3` and `objectStorage`: the app stores files through an S3 client.
     Ask whether to keep the storage it uses now (named when it is a known host such as Amazon S3,
     Cloudflare R2 or Supabase Storage) or let OpsCrew create a bucket (`false`), and if so where,
     from the `objectStorage` choices with their prices. Each choice says where it is offered:
     offer only those that fit the provider the user picks. Garage keeps the files on the app's
     server; on a cloud server it is offered only as the user's choice, so say that the files then
     live only on that server. Never replace the app's storage unasked: without an answer OpsCrew
     keeps it and plans no bucket. An answer never moves a bucket the plan already has; moving it
     is a change of `storage.objectStorage.provider` in `opscrew/plan.yaml`, and the user moves
     the files.

   For `portainer` (a Portainer the user already runs, often on a NAS such as Synology, Unraid or
   TrueNAS), ask the returned `portainer.` questions instead: Portainer's HTTPS address, the
   environment, how new versions reach Portainer and how visitors reach the app.
   - The access token comes only from `PORTAINER_API_KEY` in the MCP server's environment. Never
     ask for it, a Portainer password or any other token in the chat; tell the user to create the
     token in Portainer (My account → Access tokens) and set it in their shell.
   - When OpsCrew connects the first time (or the certificate changed), the user confirms
     Portainer's certificate fingerprint in a dialog. Never confirm it for them and never pass a
     fingerprint yourself. If the client cannot show the dialog, ask them to run
     `npx opscrew interview` in a terminal once, then continue.
   - The address stays on the user's computer; tool results say "your Portainer" instead. Never
     write it into a file, a commit or a pull request.
   - New versions: `updater` (recommended: a tiny updater stack asks Portainer once a minute over
     the local network; nothing opened to the internet) or `cloudflare` (GitHub Actions calls
     Portainer through Cloudflare Access at once; needs the domain on Cloudflare and Zero Trust).
   - Visitors: `tunnel` (recommended with a domain on Cloudflare; no port opened), `proxy` (the
     user's own reverse proxy on a host port they choose; OpsCrew prints the DNS record and the
     route to set up and does not manage the certificate) or `caddy` (Caddy on ports 80 and 443,
     only when they are free; OpsCrew prints the DNS record to create).
4. **Design.** Call `opscrew_design_options`. Present each option with its monthly cost, whether it
   fits the budget and its trade-offs; say which one OpsCrew recommends and why. When prices are
   marked as static estimates, say so. After the user chooses, call `opscrew_select_design`.
   - **Components.** Both tools list what runs next to the web process: workers (with their command
     and number of copies), cron jobs (in UTC), the services OpsCrew runs as containers (databases,
     Valkey, or Redis 8 or Dragonfly when the app needs one, brokers, search and analytics
     engines, each with its version and memory limit), SQLite
     on a persistent volume, and object storage with each option's monthly price. Each option shows
     every component's memory limit and its share of the server's monthly price. Pass on the
     licence sentences in plain words (MongoDB's SSPL, MySQL's GPL, Redpanda's BSL and so on): they
     restrict offering that software as a service, not running it for the user's own app. The
     user changes counts, memory, versions or the storage provider, or turns a component off, in
     `opscrew/plan.yaml`; then call `opscrew_select_design` again (it keeps those edits and checks
     that everything fits the server).
   - **Builder and deploys.** `opscrew_select_design` returns who builds the image (OpsCrew's
     Dockerfile for Node and Python, Buildpacks for Go, Java, .NET and Ruby, Railpack for Bun
     projects and the rest, or the repository's own Dockerfile), how deploys switch, and the signing, deploy tag
     and image retention settings, each with its reason; pass them on. Deploys have no downtime: the
     new version gets traffic only once its health check passes. Servers under 1 GB and Portainer
     restart the app instead: requests wait meanwhile, so visitors see a short pause, not an
     error. Tell the user that migrations must keep working with the version still running. They change these settings in `opscrew/plan.yaml` (the
     builder needs `app.build.chosen: user`); then call `opscrew_select_design` again.
   - **The reverse proxy.** The server's proxy is Caddy (the default) or nginx with certbot, set as
     `target.edge` in `opscrew/plan.yaml`; both give visitors the same TLS and headers. Every app on
     one server uses the same proxy, so OpsCrew refuses a different one while other OpsCrew apps are
     there; with one app the switch is planned, with a short interruption it states. The user's own
     rules for the app go in `opscrew/proxy/additions.caddy` or `.nginx`; OpsCrew checks them before
     they go live and refuses unsafe ones. For an app leaving Vercel, design writes the
     `vercel.json` routing rules it can translate into that file and lists the ones it left out:
     pass that list on. A Portainer stack always uses Caddy and does not use additions yet.
   - **Currencies.** When the user's budget is in another currency than the provider's prices,
     OpsCrew converts with a fixed approximate rate. If the user wants exact numbers, search the
     web for today's exchange rate, recalculate the monthly costs with it and say which rate and
     date you used.
   - **Own server.** OpsCrew connects over SSH and inspects the server read-only first. The first
     time, the user confirms the server's SSH host key in a dialog: tell them to compare the
     fingerprint with the one on the server itself (`ssh-keygen -lf
     /etc/ssh/ssh_host_ed25519_key.pub` in their provider's web console), never approve it for
     them. If the client cannot show the dialog, ask them to run `npx opscrew design` in a
     terminal once. There is one option and no price (they already pay for the server); pass on
     every warning (too little memory or disk, a tunnel without a Cloudflare domain) and say that
     OpsCrew cannot turn on backups there, so backups are their responsibility.
   - **Existing Portainer.** There is one option, without a price or sizes: the app and its
     components as stacks in the chosen environment, built for its architecture, with the data in
     named volumes. Say that OpsCrew does not change the host (its firewall, SSH and Portainer's
     own security stay theirs), that it checks nothing about the host's memory or disk, and that
     backups of the app's data are their responsibility. OpenSearch and Elasticsearch need a
     kernel setting on the Docker host (`vm.max_map_count`), which a stack cannot make, so OpsCrew
     does not run them in Portainer: design keeps the app's own (for example a hosted one), and a
     plan that asks for one stops before any change, saying how to make the setting and run the
     engine by hand next to Portainer.
5. **Prepare the code.** Read `opscrew/plan.yaml` (`codeChanges`). Apply the pending changes by
   following the `opscrew-adapt` skill. Show the user the diff and let them commit it (or commit it
   for them only after they say so). The changes must be merged into the deploy branch before the
   first deploy.
6. **Preview the infrastructure (optional).** Call `opscrew_render` and `opscrew_plan` to show which
   files OpsCrew will add and exactly which cloud resources it will create, with cost and policy
   checks.
7. **Provision.** Prefer the user's own terminal, because it is the only place where they can type
   production values (API keys for their app) directly into OpsCrew:

   ```bash
   npx opscrew provision
   ```

   It checks the token of their cloud provider (Hetzner Cloud or DigitalOcean), plus their
   Cloudflare and GitHub tokens, shows the plan and asks for approval, creates the server, stores
   the deploy secrets in GitHub and opens a pull request with the infrastructure and deploy
   workflow. Merging that pull request deploys the app.

   If the user wants to approve from here instead, call `opscrew_plan`, show them the plan, cost
   and policy results, then call `opscrew_apply` with the plan hash. OpsCrew asks the user in a
   dialog (a destroying plan needs them to type the server name); never claim approval yourself.
   Unless `OPSCREW_APP_ENV_FILE` is set, the deploy secrets still have to be stored by running
   `npx opscrew provision` in their terminal afterwards (it resumes without changing the server).

   **The recovery copy of the app's secrets.** The first provision that generates secrets makes an
   encrypted copy of them, of the deploy key and of the infrastructure state, committed as
   `opscrew/secrets.sops.json` with OpsCrew's pull request. Only the user's age key opens it. In a
   terminal OpsCrew asks for the user's own age key or where to write a new one. Through
   `opscrew_apply`, ask the user for a full path outside the repository (for example in their home
   folder) and pass it as `keyOut`: OpsCrew writes the new key there once and never returns it.
   Tell the user to move it into their password manager and delete the file. Never read that file,
   and never ask for the key itself. Without `keyOut` no copy is made yet, and the result says so.
   Through the tools, a public repository's copy is never committed: OpsCrew writes only the
   folder copy, if one is set, and says to run `npx opscrew provision` (or `secrets rotate`) in a
   terminal, which asks.

   **Origin protection.** For hostnames behind Cloudflare's proxy, OpsCrew serves a Cloudflare
   origin certificate and requires a client certificate only the user's zone sends, so only
   Cloudflare acting for their zone reaches the server. Its changes are in the Cloudflare plan.
   It needs **Zone → SSL and Certificates → Edit** and **Zone → Zone Settings → Edit** on the
   Cloudflare token; without them the site keeps its current protection and the result names the
   missing permission.

   **Workers and cron jobs** need nothing extra: the deploy starts the services first, then the
   new web version, then the workers and the cron jobs with the same image. When a new worker does
   not stay up, the deploy puts the app back on the previous version and names the worker and
   `docker compose logs <worker>`.

   **The storage key.** When the plan keeps the app's files in a bucket on Cloudflare R2,
   DigitalOcean Spaces or Hetzner Object Storage, the bucket has a plan of its own:
   `opscrew_plan` returns it as `storagePlanHash`, with its changes and monthly price. Show it to
   the user and pass `storagePlanHash` to `opscrew_apply` with the other hashes; it is approved in
   its own dialog, never with another plan. In the user's Portainer, the stack plan follows once
   the bucket exists, in a dialog after that apply. The app also needs a key the user creates in
   the provider's console: OpsCrew never creates one (Spaces' and Hetzner's before the bucket,
   R2's after it, as a token limited to the bucket). It comes only from the user's own terminal:
   `npx opscrew provision` shows the console steps and asks for it there once, or the user sets
   `OPSCREW_STORAGE_ACCESS_KEY_ID` and `OPSCREW_STORAGE_SECRET_ACCESS_KEY` in the MCP server's
   environment. When a tool stops because the key is missing, pass on its steps. OpsCrew keeps the
   key on the user's computer like a provider token, never in the repository or in infrastructure
   state. Never ask for the key in the chat. R2 also needs the Cloudflare token's
   **Account → Workers R2 Storage → Edit** permission. Garage, on the user's own server or
   Portainer, needs no key from them: OpsCrew generates it.

   **The Cloudflare plan comes first.** When the app's domain is on Cloudflare, `opscrew_plan` also
   returns a Cloudflare plan with its own hash (`cloudflarePlanHash`), approved on its own before
   the infrastructure plan. It lists what OpsCrew changes in its two shared rules in the zone (the
   probe block for paths like `/.env` and the strict TLS rule, each listing the hostnames of every
   OpsCrew app in the zone) and, when Cloudflare Access is used, in the account's Zero Trust
   organization and one-time PIN login. Show it to the user and say that the zone's other rules
   stay exactly as they are. Pass `cloudflarePlanHash` to `opscrew_apply` with the other hashes
   (only it when `opscrew_plan` returned nothing else); without it, the Cloudflare plan is refused.
   When the plan removes OpsCrew's rule (the last app's hostnames leave it), the dialog asks the
   user to type the app's name. In a terminal script the Cloudflare plan's hash is one more
   `--approve <hash>`, and `--yes` never approves it. When the plan stops because a hostname is
   already in OpsCrew's rule for another app, pass on its explanation: OpsCrew never takes a
   hostname from another app.

   **On the user's own server** there are two plans besides the Cloudflare plan, each approved on
   its own: the Cloudflare infrastructure (DNS records, or the tunnel and Cloudflare Access in
   tunnel mode) and the host plan, which lists every change OpsCrew would make on the server (Docker 28 or newer from
   Docker's repository, a `deploy` user, SSH hardening, the ufw firewall, fail2ban, automatic
   security updates, the shared folders `/srv/edge`, `/srv/apps` and `/srv/data`, the app's own
   `/srv/apps/<app>` and `/srv/data/<app>` and its record in `/etc/opscrew/apps/<app>.json`, and
   `vm.max_map_count` when a search engine needs it) and marks what is already in place. Several
   OpsCrew apps can share one server: each has its own folders, and all sit behind one shared edge
   (Caddy and kamal-proxy in `/srv/edge`), which goes with the last app; the host plan shows which
   parts are shared and which are the app's own. Show both plans to the user. `opscrew_plan` returns `planHash` and `hostPlanHash`; pass both to `opscrew_apply`
   (only `planHash` when it returned only a host plan). In a terminal the user runs
   `npx opscrew provision`, or in scripts `npx opscrew provision --plan-only` and then
   `--approve <infrastructure hash> --approve <host plan hash>`; `--yes` never approves either.
   When other users on the server log in with a password and have no SSH key, OpsCrew stops until
   the user chooses: ask them whether to turn password logins off anyway (`off`) or keep them
   (`keep`), never choose for them, and pass their answer as `passwordLogins` to both `opscrew_plan`
   and `opscrew_apply` (in a terminal: `--password-logins off|keep`). The host plan then shows the
   choice in the dialog they approve.
   OpsCrew stops and changes nothing when the server already runs a web server on ports 80/443,
   uses another firewall, has Docker from Snap, Podman or the distribution's package, or when
   another OpsCrew app on the server already uses the app's name or one of its hostnames; pass on
   its explanation. Tunnel mode and Portainer's subdomain need
   Cloudflare Zero Trust turned on for the account (free plan, https://one.dash.cloudflare.com/).

   **In the user's existing Portainer** there are up to two plans besides the Cloudflare plan, each
   approved on its own: the Cloudflare infrastructure (the tunnel, and Access in front of
   Portainer's webhook in `cloudflare` mode) and the stack plan, which lists every change in Portainer (the app's stack,
   the updater or connector stack, the registry entry and the repository access) and marks what is
   already in place. `opscrew_plan` returns `planHash` and `stackPlanHash`; pass both to
   `opscrew_apply` (when it returned only a stack plan, pass its `stackPlanHash` as `planHash`).
   With the Cloudflare Tunnel (web `tunnel`) or Cloudflare redeploys (redeploy `cloudflare`)
   OpsCrew creates a tunnel, and the first stack plan needs its token, which exists only after the
   Cloudflare plan is applied; so that stack plan is shown in its own dialog after the Cloudflare
   apply. In a terminal: `npx opscrew provision`; in scripts `--plan-only`, then `--approve <hash>`
   for each plan (with a new tunnel in either mode that takes three runs); `--yes` never approves
   either.
   - A private image needs a read-only registry token in `OPSCREW_GHCR_READ_TOKEN` and a private
     repository a read-only repository token in `OPSCREW_REPO_READ_TOKEN`, both in the user's
     shell. If the image is public, pass `publicImage: true` (`--public-image`) only when the user
     says so; `publicImage: false` (`--private-image`) switches back. After the user rotated one
     of those tokens, pass `replaceTokens` (`--replace-token repository|registry`) once.
   - With a limited (non-admin) Portainer token and a private image, OpsCrew stops before any
     change and shows one-time steps (an administrator adds a `ghcr.io` registry entry, or the
     image is made public); pass them on and plan again afterwards.
   - For `proxy` and `caddy` OpsCrew creates no DNS record: pass on the record it prints. Switching
     how visitors reach the app takes the site down until DNS or the proxy points at the new route.
   - OpsCrew changes nothing it did not create: other stacks, volumes, registry entries and
     Portainer's own settings stay as they are.
8. **Check.** After the first deploy finishes, call `opscrew_verify` and `opscrew_secure` and walk
   the user through anything that failed. Point them to `opscrew/RUNBOOK.md` for day-to-day
   operations. For an app in the user's Portainer behind their own reverse proxy, ask where the
   proxy serves it and pass that address as `url` to `opscrew_verify` (`--url` in a terminal).

   **The deeper check**, only when the user asks for it: `opscrew_secure` with `deep: true`
   (`npx opscrew secure --deep`) also runs Lynis on the server and ssh-audit and testssl.sh in
   Docker on the user's computer, read-only, in a few minutes. Present each tool's findings by
   severity, and the known noise apart with its reason. Without Docker the two outside audits are
   skipped with how to enable them; in a Portainer only testssl.sh runs. A fix OpsCrew can make (an
   SSH setting) happens only through its approved plans, never by itself.

## Before you start and later

- **Preflight.** On a fresh machine or clone, call `opscrew_init_check` first and help the user fix
  every item marked ✗ (tools to install, tokens to create). `npx opscrew init --fix` also adds
  `.opscrew/` to `.gitignore`.
- **Drift.** When the user asks whether anything changed, or after they worked in the Hetzner Cloud,
  DigitalOcean or Cloudflare console (or on their own server over SSH, or in Portainer), call
  `opscrew_drift`. It reports changes made outside OpsCrew (attribute names only; on their own
  server also its SSH, firewall, deploy user and runtime files; in their Portainer the app's stack
  file, environment keys, redeploy setting, registry entry and deploy branch, never a value),
  changes not applied yet and edited generated files, and changes nothing. Once the app was
  deployed on a server it also names a component of the plan with no container there, a
  container the plan no longer has and a stopped one. To undo
  drift the user runs `npx opscrew provision`; to keep a console change, `opscrew/plan.yaml` must
  be updated to match first.
- **Status.** `opscrew_status` shows the server's proxy, each certificate OpsCrew manages with its
  expiry (pass on any warning: fewer than 30 days left, or a failed renewal with its reason), and
  the recovery copy's state. It lists the app's components (web process, workers, cron jobs,
  services) and, once the app was deployed, whether each runs: read over SSH on a server, or
  through Portainer's API (with `PORTAINER_API_KEY`) in the user's Portainer. On the user's own
  server it reads them only after the SSH host key was confirmed once; otherwise ask the user to
  run `npx opscrew status` in a terminal.
- **The recovery copy.** `opscrew_secrets_status` shows when the copy was last updated, how many
  keys open it and whether it is behind. On a new computer, `opscrew_secrets_restore` rebuilds
  OpsCrew's files from it with the key file the user names (`keyFile`, a full path; never read it
  yourself). When files differ, it writes nothing and lists them by name: ask the user which side
  to keep and call it again with `onConflict`. `opscrew_secrets_rotate` makes a new key, written
  only to the `keyOut` file the user names, and proposes the re-encrypted copy in a pull request
  (dialogs approve each step); older commits stay readable with the old key.
  `opscrew_secrets_folder` sets a folder the copy is also written to, or stops it with `null`.
  In a terminal: `npx opscrew secrets status|restore|rotate|folder`.
- **Getting a domain.** When the user has no domain yet, call `opscrew_domain_check` with a name
  or a domain. It checks availability live and returns the first-year and renewal prices with
  their currency, and a plain reason for names that cannot be bought through the API (taken,
  premium, extensions sold only in a registrar's dashboard). It asks Cloudflare Registrar by
  default; pass `registrar` (`porkbun`, `namecheap` or `godaddy`) to ask another registrar, or
  `compare: true` to list the prices of every registrar that is set up side by side, cheapest
  renewal first (sorted by price only, never by anything else). Each registrar is set up with
  environment variables in the user's own shell or `.env`:
  - Cloudflare uses `CLOUDFLARE_API_TOKEN` with the optional Registrar permission.
  - Porkbun uses `PORKBUN_API_KEY` and `PORKBUN_SECRET_API_KEY`; a domain the user already has
    at Porkbun also needs API access turned on for it in Porkbun's dashboard.
  - Namecheap uses `NAMECHEAP_API_USER` and `NAMECHEAP_API_KEY`, and optionally
    `NAMECHEAP_USERNAME` and `NAMECHEAP_CLIENT_IP`. The machine's public IPv4 address must be on
    the API whitelist in Namecheap's dashboard, and Namecheap only enables API access for accounts
    with 20 domains, a $50 balance or $50 spent.
  - GoDaddy uses `GODADDY_API_TOKEN`, and optionally `GODADDY_PROFILE_ID` for a saved contact
    profile.

  Buying and connecting are terminal-only on purpose; there is no tool for either. To buy, tell the
  user to run `npx opscrew domain buy <domain>` in their own terminal (add `--registrar <id>` to
  buy at Porkbun, Namecheap or GoDaddy): it shows the price and asks them to type the domain to
  confirm (scripts need `--confirm` and `--max-price`). Afterwards OpsCrew creates the Cloudflare
  zone and sets the domain in `opscrew/plan.yaml`; at Cloudflare it also turns on DNSSEC and
  leaves auto-renew on, and a domain bought elsewhere still gets its DNS moved to Cloudflare (its
  nameservers are switched). When the user already owns a domain, tell them to run
  `npx opscrew domain connect <domain>`: it copies the current DNS records into a new Cloudflare
  zone first, shows them for approval, then switches the nameservers. If old DNSSEC (DS) records
  are still published, OpsCrew removes them through the registrar's API after approval (or the
  user removes them in the registrar's dashboard when the API cannot), and the nameserver switch
  happens on a later `npx opscrew domain connect <domain>` run, at least 24 hours after they are
  gone (the run says when). When the user removes them by hand it can take up to three runs. Never
  ask the user for tokens, API keys or contact details (name, address, email,
  phone): registrars use the account's own contact, and OpsCrew never collects one.
- **Environments.** Beside prod, an app can have more environments (staging, dev, …), each with
  its own plan, state, secrets and approvals, whose build and components follow the image: an
  environment built from a branch has its own, and a promoted one copies its source's at each
  design; promote refuses a target that lacks its source's services.
  `opscrew_env_list` shows them: where each runs, how it gets new versions, where its deploy job
  runs and whether it is deployed. To add one, ask the user its few questions (where it runs, the
  user's computer `localhost` included; its size; its hostname, answer `domain`: when prod is
  behind Cloudflare's proxy the default is a name nobody can guess, `<name>-<random>.<zone>`, and
  any other name works, also in another domain the Cloudflare token covers, hidden only one level
  below its zone; a domain of its own keeps prod's cookies apart too; whether it is built on each push
  to main or promoted from another environment; where its deploy job runs: `github`, `server`,
  `local` or `host`, a runner machine with `runnerHost`; where its image is built: `build`
  `github` or `runner`) and call `opscrew_env_add` with `name` and `answers`; it designs it and
  returns its monthly cost. `opscrew_env_set` changes how it gets new versions (`from`: `main` or
  another environment) and where its jobs run (`runner`, `runnerHost`, `build`).
  `opscrew_env_remove` removes an environment that is not deployed, only when the
  user asks. Every deploy, status, drift, secrets and design tool takes `environment`: once the
  app has several, always pass the one the user means (a call without it fails with the names),
  and in the terminal commands you pass on add `--env <name>`, for example
  `npx opscrew provision --env staging`. Removing one stays in the terminal:
  `npx opscrew destroy --env <name>`, then `opscrew_env_remove`.
- **Previews of pull requests.** Suggest them when a team reviews pull requests and the app runs
  on a server: each pull request from a branch of the repository gets its own copy of the app at
  an address nobody can guess, `https://pr-<number>-<random>.<domain>`, built and deployed by
  GitHub Actions on a small server of its own, and removed with its data when the pull request
  closes. Previews need a domain of their own: a Cloudflare zone used only for previews, never the
  app's (`npx opscrew domain buy <name>` buys one, about $10 a year), behind Cloudflare's proxy
  with one wildcard record and one Cloudflare Origin CA certificate, so no public certificate names
  a preview, there is no weekly certificate limit, and the server's web ports accept only
  Cloudflare. Setup is in the user's terminal: `npx opscrew env add preview --previews --domain
  <zone>` (no default; optional `--cap <n>` or `--size <type>`, `--lifetime <days>`, `--seed
  <command>` or `--no-seed`,
  `--target own` for a server they have with nothing else on it) designs it and shows its monthly
  cost; then `npx opscrew provision --env preview`, where the user approves the plans by their
  hashes and types `<app>-preview`: a standing approval for GitHub Actions to create, update and
  delete previews within the recipe's bounds without asking again. The deploy-setup pull request
  adds the preview workflow. The app's values for previews come from `.env.preview`: test keys
  only, since anyone who can push a branch can read them. `opscrew_preview_list` with
  `environment` lists the previews (read-only); their logs are `npx opscrew preview logs <n>`,
  removing one is `npx opscrew preview destroy <n>` (the user types `pr-<n>`), and removing them
  all with their server is `npx opscrew destroy --env preview`. The pull request's comment says:
  the preview runs at its address with its commit; it runs but its seed failed (its data may be
  missing; the next push tries again); it failed at a step (a preview that ran before keeps its
  earlier version; the logs say why); the cap is reached (close or merge another pull request
  with one, or wait until one is idle for its lifetime; nothing running was stopped); the server
  refused a request outside the recipe, was busy, could not be reached, or the preview did not
  answer within 3 minutes; the image did not build; or
  the preview was removed. Previews never run for forks or Dependabot and have no login in front
  of them: a hidden address protects against guessing and certificate logs, not against a shared
  link, referrer headers, or, on a public repository, the pull request's comment and "View
  deployment". They are not for sites without a server, a Portainer or
  this computer; each runs its own empty databases (seeded when there is a seed), never prod's
  data.
- **Previews with fewer servers or one domain, only by the user's choice.** A server and a domain
  of their own stay the default: suggest neither opt-in, and when the user asks, say what it risks
  before they choose. `--share-server <env>` (for example `dev`; in the terminal, a choice in
  "Where should previews run?" with a confirmation that defaults to no) runs previews on that
  environment's server, adding no server to the cost: anyone who can push a branch then runs code
  beside that environment's data and secrets, which escaping its container would reach, and
  reaches what its server serves on its web ports, so only for an environment with test data and
  test keys. It is refused for prod or a server prod runs on, a server with other apps, without
  public SSH (closed, or a Cloudflare Tunnel), with nginx as its proxy, on a private network, not
  yet provisioned, or with a cap that doesn't fit (it says the cap that fits). While previews run
  there, that environment's `destroy`, `retarget`, proxy switch and `env remove` are refused, and
  `npx opscrew destroy --env preview` removes the previews, recipe and wildcard but never the
  server. `--app-zone` with `--domain previews.<app's domain>` puts previews below the app's own
  domain: it needs Cloudflare's Advanced Certificate Manager on that domain, a paid add-on turned
  on in Cloudflare's dashboard (which shows its price; name no figure), and Total TLS off, and
  cookies the app sets for the whole domain reach previews, where a pull request's code can read
  them, and previews can set such cookies too. It is refused without ACM, with Total TLS on, with
  records at or below the domain, or for a domain that is an app or environment hostname or a
  parent of one, each with the dashboard steps. `destroy --env preview` removes the advanced
  certificate; ACM stays on (and billed) until the user turns it off in the dashboard. Both are
  set up in the user's terminal; the previews page of the guide has the details.
- **Promotion.** An environment promoted from another (for example prod from staging) gets no
  build of its own: `opscrew_promote` with `to` deploys exactly the image its source runs. To roll
  it back, the user runs `npx opscrew rollback --env <name>` in their terminal (see "Rolling back"
  below), not a promotion. OpsCrew checks where the image was
  built and that nothing waits for provision, then the user approves the promotion's hash in a
  dialog; you never approve it, and a client without dialogs gets the terminal command
  (`npx opscrew promote --to <env>`). Its result names the hash and the deploy run; call
  `opscrew_verify` with that environment afterwards. Without image signing
  (`supplyChain.sign`) the provenance check is a consistency check, not a security gate: say so if
  the user relies on it.
- **Rolling back.** To undo a release that deploys fine but is wrong, the user runs
  `npx opscrew rollback` in their terminal, never a tool; follow the `opscrew-troubleshoot` skill's
  "A release that deploys fine but is wrong", which says what to tell them first. On a server or
  this computer the rollback asks how to treat the database (`--database snapshot|migrations|keep`
  in a script); help them choose, and name what each loses:
  - **snapshot** (the default outside prod): when the bad release's migrations changed the schema
    or the data and losing what was written since it deployed is fine, as on dev and staging;
  - **migrations**: when the plan has `app.migrations` and the rows written since must stay, but
    the tables and columns the release added (and their data) may go;
  - **keep** (the default on prod): when the release didn't change the database, or its changes
    only add what the earlier version ignores; with a destructive change on prod, suggest fixing
    forward instead.

  A snapshot or down migration is approved like a destroy: the user types the app's name, or a
  script gives `--confirm <name>`; never type or pass it for them. Snapshots exist only where
  `deploy.snapshots` is on (every environment but prod, by default; `deploy.keepSnapshots` versions
  are kept), and need this computer to reach the server. Portainer, the app's own files (SQLite,
  uploads) and a database on another server keep their data: their snapshots come in a later
  release.
- **This computer.** An environment can run on the user's computer (`localhost`, in Docker, at
  `https://<app>.localhost`). Two steps stay in their terminal: `npx opscrew localhost trust`
  (shows the command that trusts OpsCrew's local certificate authority and asks first;
  `untrust` takes it back), and, for an environment whose deploy job runs on this computer,
  `npx opscrew runner start --env <name>`, which keeps GitHub's runner in the foreground until
  Ctrl+C and then removes it from GitHub. Such an environment has no DNS: never call
  `npx opscrew dns` for it. `opscrew_plan` returns its local plan with `localPlanHash`: show it
  to the user and pass that hash to `opscrew_apply` as `planHash`.
- **Runner machines.** A machine of the user's (a home server, a spare PC, a VM with Ubuntu or
  Debian) can run environments' deploy jobs, and with `build` `runner` their builds, if its CPU is
  the server's (OpsCrew's Hetzner and DigitalOcean servers are x86-64). `opscrew_runner_host_add`
  with `name`, `user`, `host` and, unless the key is in the SSH agent, `keyFile` (ask the user;
  never a password, and never read the key file): the user confirms its host key and approves its
  host plan in dialogs. `opscrew_runner_host_list` names the machines, never their address.
  Then `opscrew_env_set` with `runner` `host` and `runnerHost`, and `opscrew_plan`: it returns the
  machine's host plan in `runnerHostPlanHashes`, which `opscrew_apply` takes as they are. The
  production server never builds: with `runner` `server`, `build` stays `github`.
  `opscrew_runner_host_remove` only when the user asks: they type the machine's name in its dialog,
  and it is refused while an environment's deploy job runs there.
- **Hardening a runner machine.** Off by default: offer it only when the user asks.
  `opscrew_runner_host_add` takes `harden` (`ssh`, `firewall`) and `sshFrom`;
  `opscrew_runner_host_set` takes `harden`, `noHarden` and `sshFrom`. `ssh` means key-only SSH
  logins with no passwords or root; `firewall` is OpsCrew's own nftables table that lets only
  `sshFrom` reach SSH. `sshFrom` must come from the user (their address or network): never guess
  it, and know that it replaces the earlier sources. Each change goes through the host plan's
  dialog, and OpsCrew refuses a firewall that leaves out the address it connects from. `noHarden`
  removes only that part, and `opscrew_runner_host_remove` takes both off.
- **Sites with no server.** A static site, or a server-rendered app with a Cloudflare adapter,
  can run on Cloudflare's hosting (`cloudflare-workers`, Workers with static assets) or, static
  only, on GitHub Pages (`github-pages`). Both need the domain's DNS on Cloudflare, and neither
  takes databases, workers, cron jobs or other server parts: design says why when a target does
  not fit. `opscrew_design_options` offers one option with the provider's published price and
  limits; pass the limits on (and for GitHub Pages GitHub's terms: no commercial use, the site is
  public, no security headers).
  - **Cloudflare's hosting** needs a second Cloudflare token of the user's holding only
    **Account → Workers Scripts → Edit**, set as `OPSCREW_CLOUDFLARE_DEPLOY_TOKEN` in their shell
    (never in the chat): `opscrew provision` stores it as a GitHub secret for the deploy
    workflow. `opscrew_plan` returns the Worker and its custom domains as `planHash`, and the
    `www` redirect in the Cloudflare plan. `adapt` adds the framework's Cloudflare adapter when
    the app needs one.
  - **GitHub Pages** needs the user's GitHub token with Pages and Administration, both Read and
    write. `opscrew_plan` returns the Pages plan (`pagesPlanHash`: Pages published by the
    workflow and the custom domain, set before any DNS record points at GitHub), the site's
    DNS-only records (`planHash`) and GitHub's domain verification record
    (`pagesVerificationPlanHash`); pass each to `opscrew_apply`, which shows each in its own
    dialog (a Pages plan with another hash is refused). When OpsCrew stops for the verification
    value, the user adds the domain in their GitHub account's Pages settings, copies the TXT
    record's value and you pass it as `pagesVerification` (in a terminal
    `--pages-verification <value>`); it is public in DNS. Afterwards pass on the one manual step
    OpsCrew prints: clicking Verify on that page. HTTPS is enforced by a later
    `npx opscrew provision` once GitHub has issued the certificate (`opscrew_status` says when).
  - **Day 2.** `opscrew_status` shows the version running, the one before and the rollback
    command. A rollback is `npx opscrew rollback` in the user's terminal (see "Rolling back"
    above): on Cloudflare's hosting an earlier Worker version with no build, on GitHub Pages a
    re-run of an earlier run, which rebuilds that commit and which GitHub allows for 30 days. On
    Cloudflare a promoted environment gets its source's exact build; GitHub Pages refuses promotion (each
    environment builds its own, and it has prod only). `opscrew_secure` says that GitHub Pages
    cannot set security headers and that Cloudflare can.
- **Monorepos.** When the repository has several deployable folders, `opscrew_interview_questions`
  asks which one the first app is (`appDir`); ask the user, never guess.
- **Several apps in one repository.** A repository can hold more than one app, for example a
  landing page in `sites/landing` and its documentation in `sites/docs`, each deployed on its own.
  The first app is the repository's own and is set up as above. Each further app gets a key and
  its own folder: `opscrew_app_add` with `key` and `dir` (for example `docs` and `sites/docs`)
  checks that the folder holds an app and that none of the names it gives (workflow, deploy
  secrets, GitHub environment, image, branches) clash, then the app has its own plans,
  environments, workflows, deploy secrets, state and approvals, and the planning steps above run
  again for it. `opscrew_app_list` names the apps, their folders and whether each is deployed.
  Every planning, deploy, status, drift, secrets, environment and design tool takes `app`: once
  the repository has several, always pass the one the user means (a further app's key, or `.` for
  the first app; a call without it fails with the apps), and in the terminal commands you pass on
  add `--app <key>`, for example `npx opscrew provision --app docs`. A further app's files are
  under `opscrew/apps/<key>/` (its plan is `opscrew/apps/<key>/plan.yaml`). The security scan
  covers the whole repository, so every app shares its report. Removing a further app stays in the
  terminal first: `npx opscrew destroy --app <key>`, then `opscrew_app_remove` only when the user
  asks (they type its key in a dialog).
- **Tearing down.** There is no tool for it on purpose. Tell the user to run `npx opscrew destroy`
  in their own terminal: it offers a snapshot first (the database is deleted with the server), asks
  them to type the server name and removes the deploy secrets from GitHub. The app's bucket stays,
  with its files, unless they add `--delete-data`. On their own server it
  never deletes or reinstalls the server: it removes the app and its Cloudflare records after they
  type the app's name, keeps the app's data unless they add `--delete-data`, and reverses
  OpsCrew's own changes to the server only with `--undo-host` (a separate plan they approve).
  There are no snapshots there: backups are their responsibility. In their existing Portainer it
  removes only what OpsCrew created (the app's stacks, its registry entry, the deploy secrets and
  the Cloudflare resources) after they type the app's name, never Portainer itself or another
  stack, and keeps the app's data volumes unless they add `--delete-data`. A site on Cloudflare's
  hosting or GitHub Pages is removed after they type the app's name (on GitHub Pages its DNS
  records first, then the site, so nobody can take the domain over); GitHub's domain
  verification record stays unless they add `--delete-verification`. On every target it
  also removes the app's hostnames from OpsCrew's shared Cloudflare rules (deleting a rule only
  when no other OpsCrew app is in it), and the account's Zero Trust organization and one-time PIN
  login stay for the other apps.
- **Portainer.** A web UI for the containers, off by default, on any server OpsCrew deploys to.
  `opscrew_portainer_enable` installs it (listening only on the server itself, reached through an
  SSH tunnel; an admin password generated on the user's computer) after the user approves the plan
  in a dialog; `subdomain` and `allowEmails` opt in to a subdomain behind Cloudflare Access.
  `opscrew_portainer_status` tells where the password file is; never read that file or print the
  password. To open Portainer the user runs `npx opscrew portainer status` in a terminal and
  starts the SSH tunnel it prints. Warn them that Portainer controls Docker, which is the same as
  root on the server, so they should keep it local unless they need the subdomain.
  `opscrew_portainer_disable` removes it and always keeps its data (users and settings). Deleting
  that data stays in the terminal: the user runs `npx opscrew portainer disable --delete-data`
  and types the app's name; the tool refuses to do it.
- **A service on a server of its own.** On a cloud server or the user's own server, any of the
  app's services (a database, a cache, a broker) can run on a server of its own, at the same or
  another provider (Hetzner, DigitalOcean) or on another server the user owns with a public
  address; the web process, workers, cron jobs and add-ons stay on the app's server. Before the
  user decides, call `opscrew_placement_estimate` with the service, the provider, the location and
  optionally the server type: pass on the new server's price, the app's new monthly total and the
  delay each query gets (with what it adds to a page making 20 queries; say so when it warns). The
  user then adds the server under `servers` in `opscrew/plan.yaml` and sets `server` on the
  service; `opscrew_select_design` (or `npx opscrew design`) checks it, sizes and prices the server
  and refuses what cannot be linked, saying what would work. A server of the user's needs its SSH
  login once: `npx opscrew design` in a terminal asks for it. The servers are joined by a private
  WireGuard network, and the service answers only there, only to the app's servers. Moving a
  live service copies Postgres in a short maintenance window that design shows first (Valkey,
  Redis and Dragonfly start empty); it runs in the user's terminal with `npx opscrew provision`
  (scripts add `--allow-downtime`), and once the app runs on the new copy,
  `npx opscrew provision --finish-move` deletes the old one after they type the service's name.
  A Kafka or Redpanda without `login: true` in its options has no login: say that only the app's
  servers reach it.
- **Devices on the private network.** The user's laptop or phone can join the app's private
  network, to reach each server's SSH, Beszel and Portainer on its private address without a
  public port or an SSH tunnel. Adding a device is terminal-only, with no tool on purpose: tell
  the user to run `npx opscrew vpn add <name>` in their own terminal (`--this-computer` for the
  computer they work on, `--qr` to scan it with the WireGuard app on a phone), because it makes the
  device's private key on their computer and writes it only into the device's configuration
  file: a private key must never pass through this chat, a model or a log. The same goes for
  `npx opscrew vpn grant <device> <service>` (the device's own login to a database or cache,
  read-only unless `--full`; it prints the password once in the terminal), `vpn revoke`,
  `vpn remove` and `npx opscrew secure --close-public-ssh` (once deploys run on the server's own
  runner and this computer is a device, SSH answers only on the private network;
  `--open-public-ssh` opens it again). `opscrew_status` lists the devices by name and address with
  their last handshake, and the grants; never ask for or read a device's configuration file.
- **Moving the app somewhere else.** Moving a live app to another cloud provider, to a server
  the user owns or into their Portainer (and from any of these to another) is terminal-only on
  purpose; there is no tool for it, and you must not try to perform its steps yourself. Tell the
  user to run `npx opscrew retarget --to <hetzner|digitalocean|own|portainer>` in their own
  terminal (`npx opscrew retarget --help` explains the flags). For their own server or Portainer
  it asks the same questions as the interview (how to reach the server; Portainer's address,
  certificate and environment, and its host's public address). It prepares the new side next to
  the old one and deploys the app there, then copies the app's data in a maintenance window: it
  shows each data mount's size and the expected minutes first, and asks for each service whether
  to copy its data or start it empty (`--empty <service>` in scripts). Then it switches DNS.
  After checking the app, the user runs `npx opscrew retarget --finish` to remove the old side
  (it asks them to type the app name): an old server OpsCrew created is deleted, and from their
  own server or Portainer only the app and its data are removed, never the server or Portainer
  itself. `npx opscrew retarget --abort` restores the old deployment, but only before the DNS
  switch. A server at a cloud provider is billed until the move is finished. `opscrew_status`
  (or `npx opscrew status`) shows where a move stands; while it is in progress, design,
  provision and drift stop with the commands to continue, finish or abort it, so pass those on to
  the user.

If a tool returns an error, read its message and hint to the user and suggest the next step; do not
work around OpsCrew's checks.
