XIAOYU(1) — THE COMPLETE MANUAL
A reference to keep.
# xiaoyu ```text ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣤⣤⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⡿⠿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⠋⢀⣠⣾⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⡿⣡⣾⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣰⣿⣷⠿⠟⠛⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⢀⣠⣤⣶⣶⣦⣤⡀⢿⣿⡿⣿⣥⣤⣂⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⣠⣶⣿⣿⣿⠟⠛⠛⣻⣿⣿⣏⠁⠀⠹⣿⣿⣿⣷⣦⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠻⣿⣿⠟⠁⢀⣠⣾⣿⡿⣿⣿⣄⠀⠀⠉⠙⠛⠿⠿⠿⠿⠽⢽⣿⣿⡿⢿⣿⣿⣿⣶⣄⠀⠀⠀ ⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡟⠀⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⢿⣿⣿⣧⠀⠀ ⠀⠀⠀⠀⢻⣿⣿⣿⣿⠟⠁⠀⢸⣿⡿⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⡀⠀ ⠀⠀⠀⠀⠀⠉⠚⠋⠀⠀⢀⣠⣾⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣷⠀ ⠀⠀⢀⣠⣴⣶⣶⣶⣿⣿⣿⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⣿⡟⠋⠓ ⠀⣴⣿⠇⠉⠉⠛⠛⠙⠛⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠃⠀⠀ ⣴⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⢻⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠳⡏⠋⠉⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ``` **Static sites, tied together.** · xiaoyu.sh ## What it does `xiaoyu` is a single command-line binary that puts a folder of static files online at your own domain, over HTTPS, on AWS. Point it at a directory, tell it your domain, and it creates and maintains everything needed to serve that directory: - a **private S3 bucket** (never public, versioned) for the files; - a **CloudFront distribution** that reads S3 through Origin Access Control, with compression, HTTP→HTTPS redirect, TLS 1.2+, and AWS's managed security headers; - a **TLS certificate** from ACM in `us-east-1`, validated by DNS; - **atomic releases**: each deploy uploads a new immutable, content-addressed release, and CloudFront switches to it only once every byte is in place, so visitors never see a half-updated site — and you can roll back; - **verification**: after deploying, it can fetch your live HTTPS URLs and compare their SHA-256 with your local files; - **aliases and redirects**: extra hostnames like `www.`, all redirected to one canonical host, plus directory URLs or SPA routing. It also creates a dedicated, least-privilege IAM user for day-to-day deploys, so your admin credentials are only needed once. There is no AWS CLI, no Node.js, no Terraform, no daemon, no database. One binary, one small YAML file. **Requirements:** an AWS account (commercial regions only — China and GovCloud are rejected), a domain you control DNS for, and Go 1.25+ if you build from source. ## Install Download a prebuilt binary for macOS or Linux from xiaoyu.sh, verify it against the published `SHA256SUMS.txt`, and put it on your PATH. The builds are unsigned, so macOS quarantines them: clear it with `xattr -c xiaoyu` after extracting. Or build for your machine: ```sh make build ./bin/xiaoyu version ``` Or install with Go 1.25 or newer: ```sh go install github.com/klodio/xiaoyu/cmd/xiaoyu@latest ``` Cross-compile for another machine, for example a Linux CI runner: ```sh GOOS=linux GOARCH=amd64 go build -trimpath -o xiaoyu-linux-amd64 ./cmd/xiaoyu ``` Copying that one executable to another machine is enough. That machine needs only system CA certificates and AWS credentials from the standard AWS SDK chain (environment variables, a profile/SSO session, web identity, or an instance/task role). ## Quick start Run everything from the folder that contains your website files. ### 1. Create the configuration ```sh xiaoyu init ``` An interactive wizard explains every setting, recommends a default, validates your answers, and writes `.xiaoyu.yaml` after you confirm. It never asks for secrets. Prefer editing by hand? Copy `.xiaoyu.example.yaml` to `.xiaoyu.yaml` instead. Then check it — this is offline and needs no credentials: ```sh xiaoyu config check ``` ### 2. Get provisioning credentials AWS will not let an unauthenticated tool create an IAM user, so the first-time setup needs an identity that already exists. Root credentials are refused either way. Pick one of these: **A. Launch the bootstrap stack** (no IAM work by hand). The launch link on xiaoyu.sh opens CloudFormation with [`cloudformation/bootstrap.yaml`](cloudformation/bootstrap.yaml), which creates one temporary IAM user scoped to this account and outputs its access key. Then hand that key to xiaoyu: ```sh xiaoyu login ``` `login` prompts for the key (the secret is not echoed), proves it works with `sts:GetCallerIdentity`, and offers to save it with mode `0600` outside your site folder. `--no-save` verifies without writing anything. The stack's identity can create this site's infrastructure and the runtime IAM user — it is scoped, not harmless. **Delete the stack once step 5 finishes**; that deletes the user and revokes the key. It deliberately cannot run `xiaoyu destroy`. The stack is created in `ap-northeast-2`, where the template is hosted; that is unrelated to your site's region. **B. Use your own administrator credentials**, ideally temporary ones from an admin role: ```sh export AWS_ACCESS_KEY_ID='temporary-bootstrap-key' export AWS_SECRET_ACCESS_KEY='temporary-bootstrap-secret' export AWS_SESSION_TOKEN='temporary-bootstrap-token' # if applicable ``` `xiaoyu login` accepts these too, if you would rather keep them in a file than in your shell history. ### 3. Provision the site ```sh xiaoyu bootstrap-user --deploy ``` The first run normally stops with **exit code 2**: ACM is waiting for DNS validation. It prints the exact CNAME record to add. No IAM user or access key is created yet. ### 4. Add the certificate-validation DNS record Add the printed CNAME at your DNS provider. If you use Namecheap, the tool can do it for you (see Namecheap DNS). Keep this record forever so ACM can renew the certificate automatically. ### 5. Finish provisioning and get your deploy credentials Once ACM reports `ISSUED`, run the same command again: ```sh xiaoyu bootstrap-user --deploy ``` It finishes CloudFront, publishes the first release, then creates an IAM user scoped to exactly this site's bucket, certificate, distribution, OAC, and routing function. It verifies that user's new key actually works, writes it with mode `0600` to your OS user-config directory (outside your website folder), and prints the path. Finally, add the printed site DNS record (ALIAS/CNAME to CloudFront) for each domain and alias. ### 6. Use the dedicated credentials from now on ```sh source '/path/printed/by/xiaoyu/example.com-xxxxxxxxxxxx.env' xiaoyu status ``` That file exports the AWS key plus the site settings that were in effect, so a fresh shell is enough to deploy. Keep it private; it holds a long-lived secret. ### 7. Verify the live site ```sh xiaoyu verify --wait # index plus one sample file xiaoyu verify --all --wait # every file ``` Verification downloads your public HTTPS URLs and compares SHA-256 digests with the local files, checking every alias redirect on the way. `--wait` waits for CloudFront to finish deploying and invalidating first. Leave `verify_after_deploy: false` until DNS resolves; then turn it on so every deploy checks itself. That's it. The everyday loop afterwards is: edit files → `xiaoyu deploy`. ## Everyday use ```sh xiaoyu deploy # publish the current folder as a new release xiaoyu upload # upload all files (no AWS config reconciliation) xiaoyu upload --file app.css # upload a single file xiaoyu sync --dry-run # show exactly what would change xiaoyu sync # publish changes xiaoyu sync --delete --yes # also drop files that no longer exist locally ``` `upload` and `sync` never delete anything unless you pass `--delete`. With `atomic_deploy: true` (the default) all three create an immutable release and switch CloudFront only when it is complete. Changed files are detected by SHA-256, stored as object metadata — not by timestamps or ETags. Unchanged files are copied server-side rather than re-uploaded. If you changed settings that affect AWS itself (new alias, new routing mode, changed security headers), the dedicated user is intentionally not allowed to do that. Restore admin credentials and run: ```sh xiaoyu bootstrap-user --provision ``` which reprovisions and refreshes the user's policy without rotating its key. ## Configuration `.xiaoyu.yaml` in the current directory holds non-secret settings. `XIAOYU_CONFIG` selects a different file. Every setting has an environment override, which wins over the YAML value. Unknown YAML keys are rejected. ```yaml domain: example.com aliases: - www.example.com canonical_domain: example.com region: eu-west-1 source: . routing: directory ``` | YAML | Environment override | Default | |---|---|---| | `domain` | `XIAOYU_DOMAIN` | required | | `aliases` | `XIAOYU_ALIASES` (comma-separated) | none | | `canonical_domain` | `XIAOYU_CANONICAL_DOMAIN` | primary domain | | `region` | `AWS_REGION` | required | | `source` | `XIAOYU_SOURCE_DIR` | current directory | | `bucket` | `XIAOYU_BUCKET` | deterministic account/domain name | | `routing` | `XIAOYU_ROUTING` | `files` | | `security_headers` | `XIAOYU_SECURITY_HEADERS` | `true` | | `atomic_deploy` | `XIAOYU_ATOMIC_DEPLOY` | `true` | | `retain_releases` | `XIAOYU_RETAIN_RELEASES` | `5` | | `verify_after_deploy` | `XIAOYU_VERIFY_AFTER_DEPLOY` | `false` | | `verify_all_files` | `XIAOYU_VERIFY_ALL_FILES` | `false` | | `index_document` | `XIAOYU_INDEX_DOCUMENT` | `index.html` | | `error_document` | `XIAOYU_ERROR_DOCUMENT` | `404.html` when present | | `price_class` | `XIAOYU_PRICE_CLASS` | `PriceClass_100` | | `asset_cache_control` | `XIAOYU_CACHE_CONTROL` | `public,max-age=300` | | `html_cache_control` | `XIAOYU_HTML_CACHE_CONTROL` | `no-cache` | | `exclude` | `XIAOYU_EXCLUDE` (comma-separated) | none | | `log_bucket` | `XIAOYU_LOG_BUCKET` | disabled | | `log_prefix` | `XIAOYU_LOG_PREFIX` | empty | Exclude patterns use Go `path.Match`. Some things are never uploaded, whatever you configure: `.git`, `.DS_Store`, `.env*`, `.xiaoyu.env*`, `.xiaoyu.yaml`, `.xiaoyu.yml`, `.release.json`, the `xiaoyu`/`xiaoyu.exe` binaries, the the tool's internal `_xiaoyu/` and `_s3site/` namespaces, Open Design directories (`.od/`, `.od-skills/`, `open-design/`, `.open-design/`), symlinks, and non-regular files. Similarly named public assets such as `xiaoyu.css` upload normally. ### Routing - `files` — direct object paths only; `/` serves `index_document`. - `directory` — `/about` and `/about/` serve `/about/index.html`. - `spa` — extensionless paths serve the root `index_document`. Rewrites and canonical-host redirects run in one managed CloudFront Function. Every alias goes into both the certificate and the distribution; requests to a non-canonical hostname get a `301` that preserves path and query string. `directory` and `spa` reject extensionless source files whose public paths the rewrite would capture. ## Commands | Command | Purpose | |---|---| | `xiaoyu init` | Interactive configuration wizard (alias of `config init`) | | `xiaoyu config check` | Validate local configuration and files, offline | | `xiaoyu login` | Verify pasted provisioning credentials and store them privately | | `xiaoyu bootstrap-user` | Provision AWS and create/rotate/repair the dedicated user | | `xiaoyu deploy` | Reconcile AWS settings and publish a release | | `xiaoyu plan` | Preview AWS and content changes, read-only | | `xiaoyu status` | AWS, DNS, HTTPS, release, and 24h CloudWatch metrics | | `xiaoyu doctor` | Diagnose local, AWS, DNS, HTTPS, redirect, and content health | | `xiaoyu upload` | Upload all files, or one with `--file` | | `xiaoyu sync` | Checksum sync, optionally with `--delete` | | `xiaoyu verify` | Compare live HTTPS responses with local SHA-256 | | `xiaoyu releases` | List releases, or `--prune --yes` old ones | | `xiaoyu rollback --yes <id>` | Point CloudFront back at a retained release | | `xiaoyu dns` | Plan or apply Namecheap DNS records | | `xiaoyu destroy` | Plan, or confirm, guarded teardown | | `xiaoyu version` | Print the version | `status`, `releases`, `rollback`, `dns`, and `destroy` work without the local website folder. `plan`, `deploy`, `upload`, `sync`, `verify`, and `bootstrap-user --deploy` need it. Run `xiaoyu` with no arguments for the full usage text. ## Releases and rollback Each release is stored under a content-addressed prefix: ```text _xiaoyu/releases/<release-id>/ ``` Sites provisioned before the rename keep `_s3site/` instead; both are read, and neither is ever published. CloudFront's origin path is switched only after the complete release and its manifest are verified in S3. Old releases stay immutable, so rollback is just a pointer change: ```sh xiaoyu releases # list, with integrity status xiaoyu rollback --yes <release-id> # go back xiaoyu releases --prune --yes # delete inactive ones beyond retain_releases ``` Pruning keeps every release younger than one hour, re-checks the active release before each deletion, and waits for CloudFront and its invalidations to settle. Deploy, rollback, prune, and destroy coordinate through a renewable S3 lease so two of them can never act on a half-written release. ## Checking a site ```sh xiaoyu plan # what would change, read-only xiaoyu status # AWS/DNS/HTTPS state and CloudFront metrics xiaoyu doctor # actionable health checks, including a live byte comparison xiaoyu verify # SHA-256 check of the live site ``` For subdomains, DNS status requires an exact CNAME match to the distribution. At an apex domain, ALIAS/ANAME flattening hides the target, so a successful lookup is reported as indeterminate rather than as proof. ## Namecheap DNS Every command that needs DNS prints records you can add by hand. If your domain is at Namecheap, automation is available and always plan-first: ```sh export NAMECHEAP_API_USER='...' export NAMECHEAP_API_KEY='...' export NAMECHEAP_USERNAME='...' export NAMECHEAP_CLIENT_IP='your-whitelisted-public-ip' xiaoyu dns # show the plan xiaoyu dns --apply --yes # apply it ``` It reads your complete current record set, replaces only conflicting records for the hosts it needs, keeps everything else (MX, TXT, …), and re-reads all records immediately before writing so it aborts if anything changed since the plan. Namecheap has no conditional-write token, so a small unavoidable race remains. All aliases must currently share one registrable domain; manual instructions support aliases in different zones. ## Access logs Set `log_bucket` to enable CloudFront standard (legacy) access logging: ```yaml log_bucket: my-cloudfront-log-bucket log_prefix: example.com/ ``` The bucket must already exist, be separate from the site bucket, and be configured for CloudFront log delivery. Legacy logging needs bucket-ACL permissions, so it does **not** work with the dedicated runtime user — use it only if you deploy under an administrator-managed role. The site bucket's private, bucket-owner-enforced configuration is never weakened for logging. ## Tearing down `destroy` is plan-only unless you confirm: ```sh xiaoyu destroy # show what would be deleted xiaoyu destroy --retain-bucket --yes # delete the edge, keep the bucket xiaoyu destroy --yes # delete everything it owns ``` It only touches resources whose ownership tags and private ownership marker prove they belong to this site, and re-checks that immediately before each deletion. Namecheap DNS records and the IAM user are left behind and reported so you can clean them up deliberately. The dedicated deploy user has no deletion rights — run destroy with administrator credentials. After `--retain-bucket`, the bucket and all its releases remain but the distribution, OAC, certificate, and routing functions are gone. To bring the site back, restore the same configuration and administrator credentials, keep `XIAOYU_IAM_USER` pointing at the existing user, and run: ```sh xiaoyu bootstrap-user --provision ``` This rebuilds the edge around the retained release without needing your local files at all. If the retained release is missing or unverifiable, it fails rather than silently publishing something else. You may need to complete a new ACM validation and rerun the command. ## How it stays safe A few design choices worth knowing: - **S3 is never public.** The public site is CloudFront, which signs its origin requests with Origin Access Control. The S3 website endpoint is never enabled. The bucket policy requires TLS, requests are bound to the expected AWS account, and deployment refuses to preserve unexpected access-granting policy statements. - **Admin credentials are temporary.** They provision infrastructure once; the long-lived user created afterwards is scoped to the exact ARNs of this site and can only deploy content. It cannot create certificates, distributions, IAM resources, or delete infrastructure. - **Deploys are all-or-nothing.** Content is verified in S3 before CloudFront ever points at it, and routing code is content-addressed so a new rewrite is never served against an old release. - **Verification is byte-exact.** Comparisons use SHA-256 of the actual HTTPS response body, not ETags or timestamps. ## Exit codes - `0` — success - `1` — configuration, verification, or AWS operation failed - `2` — waiting for ACM DNS validation (add the printed CNAME and rerun) ## Development ```sh make build # ./bin/xiaoyu make release VERSION=v1.2.3 make test # never contacts AWS make vet fmt ``` `make release` requires a strict SemVer 2.0 version with a leading `v` and checks the finished binary. ### Real-AWS integration tests These are behind a build tag and an explicit opt-in because they create billable resources. Use a dedicated test domain and account. ```sh export AWS_ACCESS_KEY_ID='...' export AWS_SECRET_ACCESS_KEY='...' export AWS_REGION='eu-west-1' export XIAOYU_INTEGRATION_DOMAIN='xiaoyu-test.example.com' export XIAOYU_RUN_AWS_INTEGRATION=1 make test-integration make test-integration-compile # compile only, no AWS ``` The first run may stop for ACM validation; add the printed CNAME and rerun, then add the site DNS record. Use `XIAOYU_INTEGRATION_SKIP_CUSTOM_DOMAIN=1` only while that record is pending. The suite covers provisioning idempotency, anonymous S3 access returning `403`, HTTP→HTTPS redirects, byte-exact content over the custom domain, sync and single-file invalidation, atomic releases and rollback, and resource reuse. Resources are deliberately left in place; run `xiaoyu destroy --yes` when finished. Additional gated suites: - `XIAOYU_RUN_RETAIN_RECOVERY_INTEGRATION=1` — destructive `destroy --retain-bucket` and recovery round-trip. - `XIAOYU_RUN_IAM_INTEGRATION=1` — creates and cleans up a dedicated IAM user (the test domain's distribution must already exist). - `XIAOYU_RUN_IAM_DEPLOY_INTEGRATION=1` — additionally provisions and proves the generated user can deploy. `XIAOYU_INTEGRATION_BUCKET` overrides the test bucket, `XIAOYU_INTEGRATION_TIMEOUT` (default `30m`) sets the lifecycle timeout, and `XIAOYU_INTEGRATION_TEST_TIMEOUT` (default `35m`) sets Make's outer Go test timeout, which must exceed the lifecycle timeout by more than two minutes. ### The project website [`website/`](website/) holds the xiaoyu.sh site: plain HTML/CSS, system monospace fonts, no dependencies or external assets. The online manual is generated from this README, so documentation stays in sync. ```sh make website # downloads + pages, into dist/website/ make website VERSION=v1.2.3 # stamp a real version into the binaries make website-preview # http://127.0.0.1:8080 ``` `make website` runs `make downloads` first, which cross-compiles the binary for macOS and Linux into `dist/website/download/`, packages the tracked source with `git archive`, and writes `SHA256SUMS.txt`. The page build refuses to run if those artifacts are missing, so its download links can never 404. Artifacts are deliberately named `xiaoyu-<os>-<arch>.tar.gz`: a file named exactly `xiaoyu` is always excluded from uploads and could never be published. The version in `dist/website-version.txt` is shown on the page; it is `dev` unless you pass `VERSION`. #### Publishing the CloudFormation template The build copies [`cloudformation/bootstrap.yaml`](cloudformation/bootstrap.yaml) to `dist/website/aws/bootstrap.yaml` as the readable copy. The launch button cannot point there: CloudFormation only accepts a `TemplateURL` served from an S3 bucket, and its docs state that "URLs from S3 static websites are not supported". So the machine-readable copy needs a small public bucket of its own. The published copy lives at: ```text https://xiaoyu-bootstrap.s3.ap-northeast-2.amazonaws.com/bootstrap.yaml ``` That is the default the page is built with; set `XIAOYU_TEMPLATE_URL` at build time to point somewhere else. The launch link creates the stack in the region parsed from that URL — `ap-northeast-2` here — because CloudFormation reads the template from S3 and cross-region reads are not dependably supported. The stack holds only a global IAM user, so its region says nothing about where the site lives; override it with `XIAOYU_STACK_REGION` if you move the template. The key is not versioned, so re-uploading changes what every existing launch link creates. Edit the template in place only for fixes you want everyone to get; publish a new key for anything else. `dist/website/` is a self-contained site root: upload it to any static host, or publish it with xiaoyu from inside the folder. The build writes a `.xiaoyu.yaml` there (copied from [`website.xiaoyu.yaml`](website.xiaoyu.yaml) with `source: .`), and that filename is always excluded from uploads, so it is never published. ```sh make build website cd dist/website xiaoyu config check xiaoyu bootstrap-user --deploy # first time, with temporary admin credentials xiaoyu deploy # every time after that ``` Edit `website.xiaoyu.yaml` in the repository root to change site settings; the copy in `dist/website/` is regenerated by every build. Then follow the quick start from step 4. `xiaoyu.sh` is an apex domain, so use a DNS provider with ALIAS/ANAME or CNAME flattening. Building or previewing the website never touches AWS or DNS. ## Migrating from s3site This tool used to be called `s3site`. Existing `.s3site.yaml` files and `S3SITE_` environment variables still work. See [MIGRATING-FROM-S3SITE.md](MIGRATING-FROM-S3SITE.md).