XIAOYU(1) — THE COMPLETE MANUAL
A reference to keep.
xiaoyu
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣤⣤⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⡿⠿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⠋⢀⣠⣾⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⡿⣡⣾⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣰⣿⣷⠿⠟⠛⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⢀⣠⣤⣶⣶⣦⣤⡀⢿⣿⡿⣿⣥⣤⣂⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⣠⣶⣿⣿⣿⠟⠛⠛⣻⣿⣿⣏⠁⠀⠹⣿⣿⣿⣷⣦⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠻⣿⣿⠟⠁⢀⣠⣾⣿⡿⣿⣿⣄⠀⠀⠉⠙⠛⠿⠿⠿⠿⠽⢽⣿⣿⡿⢿⣿⣿⣿⣶⣄⠀⠀⠀ ⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡟⠀⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⢿⣿⣿⣧⠀⠀ ⠀⠀⠀⠀⢻⣿⣿⣿⣿⠟⠁⠀⢸⣿⡿⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⡀⠀ ⠀⠀⠀⠀⠀⠉⠚⠋⠀⠀⢀⣠⣾⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣷⠀ ⠀⠀⢀⣠⣴⣶⣶⣶⣿⣿⣿⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⣿⡟⠋⠓ ⠀⣴⣿⠇⠉⠉⠛⠛⠙⠛⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠃⠀⠀ ⣴⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⢻⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠳⡏⠋⠉⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
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 versionOr install with Go 1.25 or newer:
sh
go install github.com/klodio/xiaoyu/cmd/xiaoyu@latestCross-compile for another machine, for example a Linux CI runner:
sh
GOOS=linux GOARCH=amd64 go build -trimpath -o xiaoyu-linux-amd64 ./cmd/xiaoyuCopying 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 initAn 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 check2. 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`, which creates one temporary IAM user scoped to this account and outputs its access key. Then hand that key to xiaoyu:
sh
xiaoyu loginlogin 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 applicablexiaoyu 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 --deployThe 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 --deployIt 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 statusThat 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 fileVerification 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 locallyupload 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 --provisionwhich 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 │ none │ │ │ (comma-separated) │ │ │ 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 │ none │ │ │ (comma-separated) │ │ │ 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:
_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_releasesPruning 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 siteFor 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 itIt 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 ownsIt 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 --provisionThis 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 fmtmake 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 AWSThe 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/` 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:8080make 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` 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:
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` 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 thatEdit 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.