Sandbox Operations — game.holomush.dev
Day-to-day operations for the HoloMUSH project’s own sandbox at game.holomush.dev,
maintained by the core team. Self-hosters should refer to
Deploying HoloMUSH instead.
One-time bootstrap
Section titled “One-time bootstrap”The bootstrap workflow automates all cloud resource provisioning from zero in a single run. It is idempotent — safe to re-run if it fails partway through.
Seed secrets (set once, before running the workflow)
Section titled “Seed secrets (set once, before running the workflow)”Add these seven secrets to the repository via Settings → Secrets and variables → Actions → New repository secret:
| Secret | How to obtain |
|---|---|
DIGITALOCEAN_ACCESS_TOKEN | DO dashboard → API → Personal Access Tokens |
DIGITALOCEAN_SSH_KEY_ID | Fingerprint or numeric ID of an existing key in DO |
DIGITALOCEAN_SSH_PRIVATE_KEY | Matching private key for the above |
CLOUDFLARE_API_TOKEN | CF dashboard → My Profile → API Tokens (Zone:DNS:Edit + Account:Cloudflare Tunnel:Edit) |
CLOUDFLARE_ACCOUNT_ID | CF dashboard → right sidebar → Account ID |
CLOUDFLARE_ZONE_ID | CF dashboard → domain → right sidebar → Zone ID |
SECRETS_ADMIN_PAT | GitHub → Developer Settings → Fine-grained PAT with Secrets:Write on this repo |
Run the workflow
Section titled “Run the workflow”- Go to Actions → Bootstrap Sandbox → Run workflow.
- Accept the defaults (or adjust region, sizes, bucket name, etc.).
- Leave dry_run unchecked for a real provisioning run.
- Click Run workflow and monitor the run — it takes ~5–10 minutes.
The workflow creates and writes back to GitHub Secrets:
KOPIA_SANDBOX_PASSWORD— Kopia repository encryption key (generated once; back it up)CLOUDFLARE_TUNNEL_IDandCLOUDFLARE_TUNNEL_TOKENDO_SPACES_ACCESS_KEYandDO_SPACES_SECRET_KEY
After the workflow completes
Section titled “After the workflow completes”- Confirm the healthz check passed in the workflow summary.
- Back up
KOPIA_SANDBOX_PASSWORDto a secure location (1Password, sealed secret, etc.). If it is lost, existing snapshots become unrecoverable — Kopia encrypts client-side with no recovery path. - Narrow the SSH firewall rule (
22/tcp) from0.0.0.0/0to your static IP plus the GitHub Actions egress range (see https://api.github.com/meta).
Save the .env shape
Section titled “Save the .env shape”Commit a redacted .env example to scripts/sandbox.env.example if the
real shape has drifted from the committed version.
Manual bootstrap (air-gapped or debugging)
Section titled “Manual bootstrap (air-gapped or debugging)”Use these steps if the workflow is unavailable or you need to troubleshoot individual resources.
1. Create the Cloudflare Tunnel
Section titled “1. Create the Cloudflare Tunnel”In the Cloudflare dashboard:
- Zero Trust → Networks → Tunnels → Create a tunnel
- Name:
holomush-sandbox - Copy the token (starts with
eyJh...) into GitHub Secrets asCLOUDFLARE_TUNNEL_TOKEN. - Add a Public Hostname route:
game.holomush.dev → http://gateway:8080. - Save.
2. Create the Spaces bucket and Kopia password
Section titled “2. Create the Spaces bucket and Kopia password”-
DigitalOcean → Spaces → Create a Space in
nyc3, nameholomush-sandbox-backups. -
Generate an access key pair: API → Spaces Keys → Generate New Key.
-
Save both values into GitHub Secrets as
DO_SPACES_ACCESS_KEYandDO_SPACES_SECRET_KEY. -
Generate a long Kopia repository password and save it as
KOPIA_SANDBOX_PASSWORD:Terminal window openssl rand -base64 48 | tr -d '=/+' | head -c 64Store this password somewhere recoverable (1Password, a sealed secret, etc.). If it is lost, every snapshot in the repository becomes unrecoverable — Kopia encrypts client-side with no recovery.
-
No Spaces lifecycle rule is needed. Kopia manages retention internally: pinned
pre-deploy:*snapshots live forever, others are pruned by policy.
3. Create the droplet
Section titled “3. Create the droplet”The commands below assemble a cloud-init script by prepending your secrets
as exported shell variables ahead of scripts/cloud-init.sh, then passing
the result as user-data to the new droplet. Run these from your local machine:
# Render user-data from scripts/sandbox.env.example + real secrets,# then merge with scripts/cloud-init.sh as user-data.export HOLOMUSH_REF=v0.1.0 # source ref for cloud-init (tag/branch/sha; defaults to "main")export HOLOMUSH_VERSION=0.1.0 # docker image tag pulled from ghcr.io (without "v")export HOLOMUSH_DOMAIN=game.holomush.devexport HOLOMUSH_INGRESS=tunnelexport CLOUDFLARE_TUNNEL_TOKEN="..."export POSTGRES_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)"export BACKUP_S3_BUCKET=holomush-sandbox-backupsexport BACKUP_S3_ENDPOINT=nyc3.digitaloceanspaces.comexport BACKUP_S3_ACCESS_KEY="..."export BACKUP_S3_SECRET_KEY="..."export KOPIA_PASSWORD="..." # from KOPIA_SANDBOX_PASSWORD secretexport BACKUP_KEEP_DAILY=7export BACKUP_KEEP_WEEKLY=4export BACKUP_KEEP_MONTHLY=6
# Prepend env exports to the cloud-init body( printf '#!/bin/bash\n' env | grep -E '^(HOLOMUSH_|CLOUDFLARE_TUNNEL_TOKEN|POSTGRES_PASSWORD|BACKUP_|KOPIA_)' \ | sed 's/^/export /' sed -n '10,$p' scripts/cloud-init.sh) > /tmp/holomush-cloud-init.sh4. Create the block volume BEFORE the droplet
Section titled “4. Create the block volume BEFORE the droplet”Cloud-init’s mounts: directive only runs during first boot, so the
volume must exist and be attached BEFORE the droplet boots. Create it
first, then pass its ID via --volumes at droplet-create time:
VOLUME_ID=$(doctl compute volume create holomush-sandbox-data \ --region nyc3 --size 25GiB --fs-type ext4 --format ID --no-header)5. Create the droplet with the volume attached at boot
Section titled “5. Create the droplet with the volume attached at boot”doctl compute droplet create holomush-sandbox-game \ --image ubuntu-24-04-x64 \ --size s-2vcpu-2gb-amd \ --region nyc3 \ --ssh-keys "$(doctl compute ssh-key list --format ID --no-header | head -1)" \ --tag-names holomush-sandbox \ --volumes "${VOLUME_ID}" \ --user-data-file /tmp/holomush-cloud-init.shWait ~2 minutes for cloud-init to finish. Postgres’s first init will
land on the attached volume because cloud-init’s mounts: stanza runs
before Docker Compose.
6. Apply the firewall
Section titled “6. Apply the firewall”doctl compute firewall create --inbound-rules-file expects a DSL string,
not JSON. The committed deploy/doctl/firewall-sandbox.json is
REST-API-shaped (matches what the bootstrap workflow posts). Use curl:
# Substitute your SSH-allowlist CIDRs before posting.SSH_CIDRS='["203.0.113.5/32"]' # e.g. your static IP; comma-separate if moreSSH_CIDRS_JSON=$(printf '%s' "${SSH_CIDRS}")
FW_JSON=$(jq \ --argjson ssh_sources "${SSH_CIDRS_JSON}" ' .inbound_rules[] |= ( if .protocol == "tcp" and .ports == "22" then .sources.addresses = $ssh_sources else . end ) ' deploy/doctl/firewall-sandbox.json)
FW_ID=$(curl -fsS -X POST \ -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d "${FW_JSON}" \ "https://api.digitalocean.com/v2/firewalls" | jq -r '.firewall.id')
curl -fsS -X POST \ -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"droplet_ids\":[${DROPLET_ID}]}" \ "https://api.digitalocean.com/v2/firewalls/${FW_ID}/droplets"The committed JSON ships a locked-down 127.0.0.1/32 placeholder for SSH.
Always substitute your real operator CIDR allowlist before posting.
7. Wire DNS
Section titled “7. Wire DNS”game.holomush.dev— already routed via the tunnel (Step 1).telnet.game.holomush.dev— A record → droplet public IP, DNS only (grey cloud).
8. Save the .env shape
Section titled “8. Save the .env shape”Commit a redacted .env example to scripts/sandbox.env.example if the
real shape has drifted from the committed version.
Ongoing operations
Section titled “Ongoing operations”Deploy a new version
Section titled “Deploy a new version”Pushing a v* tag to main triggers the deploy-sandbox workflow. No
manual steps needed.
To deploy manually, mirror what the deploy-sandbox job does — sync the
tag’s compose file, docker/ tree, and deploy/ tree onto the droplet
BEFORE pulling images and restarting. Without the sync, compose/profile
changes or backup-image updates in the release never reach the host:
ssh holomush@game.holomush.devVERSION=v0.2.0sudo apt-get install -y git # if not already present
# Sync release assets onto the hostrm -rf /tmp/holomush-releasegit clone --depth 1 --branch "${VERSION}" \ https://github.com/holomush/holomush.git /tmp/holomush-releasecp /tmp/holomush-release/compose.prod.yaml /opt/holomush/compose.yamlrm -rf /opt/holomush/docker /opt/holomush/deploycp -r /tmp/holomush-release/docker /opt/holomush/dockercp -r /tmp/holomush-release/deploy /opt/holomush/deployrm -rf /tmp/holomush-release
# Update version pin + run the deploy sequencecd /opt/holomushsed -i "s/^HOLOMUSH_VERSION=.*/HOLOMUSH_VERSION=${VERSION}/" .envdocker compose --profile tunnel --profile backups pull core gateway cloudflareddocker compose --profile tunnel --profile backups build backupdocker compose --profile tunnel --profile backups up -d --no-recreate postgres
# Pre-migrate backfill-budget probe: the new core's synchronous audit Backfill# runs before readiness, so a large events_audit can exceed the core health# budget (~90s: start_period 15s + retries 15 × interval 5s). Above ~500k rows,# run an ahead-of-deploy backfill or temporarily raise the core start_period.ROWS=$(docker compose --profile tunnel --profile backups exec -T postgres \ psql -U holomush -d holomush -tAc "SELECT reltuples::bigint FROM pg_class WHERE oid = 'public.events_audit'::regclass" </dev/null | tr -d '[:space:]')echo "pre-migrate events_audit row estimate: ${ROWS:-unknown} (pg_class.reltuples; core health budget ~90s)"
# Sever the whole player-traffic path AND the old core before migrate, so the# old core's now-incompatible audit INSERT never runs against the 000052 schema.docker compose --profile tunnel --profile backups stop cloudflared gateway core
# `-T` + `</dev/null` guard stdin (this block may be pasted into an ssh heredoc).docker compose --profile tunnel --profile backups run --rm -T core migrate </dev/null
# Start ONLY the new core, gated on its readiness (its synchronous audit backfill# boot gate). If the gate fails, `up -d --wait` exits non-zero and aborts here —# player traffic is never restored onto a bad core.docker compose --profile tunnel --profile backups up -d --wait --no-deps core
# Restore player traffic (gateway + cloudflared) only after core is ready.docker compose --profile tunnel --profile backups up -dMigration 000052 (audit-log partitioning, shipped in the same release as the
retention worker) makes the old core’s events_audit INSERT incompatible with
the new schema (event_ms NOT NULL plus a dropped id-alone unique index). The
sequence above therefore stops cloudflared + gateway + core before
core migrate — severing the entire player-traffic path so no request reaches
a half-migrated core — runs the migration with no old core writing, starts
only the new core gated on its readiness, and restores player traffic
last. The brief audit-write outage during the migrate/readiness window is a
deliberate, bounded single-node risk; the readiness gate aborts the deploy
before traffic is restored if the new core’s audit backfill fails. The
pre-migrate row-count probe warns when a large events_audit history could push
the synchronous backfill past the ~90s core health budget.
View logs
Section titled “View logs”On the droplet, cloud-init (and the release deploy workflow) copies the
repository’s compose.prod.yaml to /opt/holomush/compose.yaml, so the
-f /opt/holomush/compose.yaml path below is correct despite the source
file being named compose.prod.yaml.
ssh holomush@game.holomush.devdocker compose -f /opt/holomush/compose.yaml logs -f core gateway cloudflaredRotate Postgres password
Section titled “Rotate Postgres password”- Generate a new password:
openssl rand -base64 24 | tr -d '/+=' | head -c 32 - On the droplet,
docker compose exec postgres psql -U holomush -d holomush -c "ALTER USER holomush WITH PASSWORD '...';" - Update
.envanddocker compose up -d core gateway backupto pick up the new value.
Recreate the tunnel
Section titled “Recreate the tunnel”If the tunnel token is compromised:
- Revoke the old tunnel in the Cloudflare dashboard.
- Create a new tunnel with the same name; copy the new token.
- Update GitHub Secret
CLOUDFLARE_TUNNEL_TOKEN. - SSH to the droplet, update
.env,docker compose up -d cloudflared.
Take a manual backup
Section titled “Take a manual backup”ssh holomush@game.holomush.devdocker compose -f /opt/holomush/compose.yaml --profile tunnel --profile backups \ exec backup /usr/local/bin/backup.shTo take a pinned snapshot that retention policy will not expire:
docker compose -f /opt/holomush/compose.yaml --profile tunnel --profile backups \ exec backup /usr/local/bin/backup.sh --tag=manual-pin:$(date -u +%F)Rotate the Kopia repository password
Section titled “Rotate the Kopia repository password”Warning: rotating the repository password means every snapshot currently in the repository becomes unreadable. There is no “re-encrypt” operation.
-
Take a final backup under the old password and download it locally.
-
Create a new bucket (or prefix) for the new repository.
-
Update
KOPIA_SANDBOX_PASSWORDin GitHub Secrets. -
SSH to the droplet, update
.envwith the new password (and bucket / prefix if changed), then explicitly initialize the new repository from thebackupcontainer —backup.shonly connects to an existing repo, it does not create one, so without this step cron backups silently fail:Terminal window cd /opt/holomushdocker compose --profile tunnel --profile backups run --rm backup \kopia repository create s3 \--bucket="${BACKUP_S3_BUCKET}" \--endpoint="${BACKUP_S3_ENDPOINT}" \--access-key="${BACKUP_S3_ACCESS_KEY}" \--secret-access-key="${BACKUP_S3_SECRET_KEY}"Old snapshots remain encrypted with the old password — keep a copy if they matter.
Restore a backup
Section titled “Restore a backup”See sandbox-restore.md.
Rebuild the droplet from scratch
Section titled “Rebuild the droplet from scratch”If the droplet is compromised or misconfigured beyond repair:
-
Detach the block volume from the old droplet:
Terminal window doctl compute volume-action detach "${VOLUME_ID}" "${OLD_DROPLET_ID}" -
Destroy the old droplet:
Terminal window doctl compute droplet delete holomush-sandbox-game -
Create the new droplet with the existing volume attached at boot so cloud-init’s
mounts:stanza mounts/opt/holomush/databefore Postgres initializes — reattaching after create would put Postgres back on ephemeral disk:Terminal window doctl compute droplet create holomush-sandbox-game \--image ubuntu-24-04-x64 \--size s-2vcpu-2gb-amd \--region nyc3 \--ssh-keys "$(doctl compute ssh-key list --format ID --no-header | head -1)" \--tag-names holomush-sandbox \--volumes "${VOLUME_ID}" \--user-data-file /tmp/holomush-cloud-init.sh -
Re-apply the firewall to the new droplet (see Manual bootstrap Step 6 for the
doctl compute firewall add-dropletscall). -
Verify the stack is up:
ssh holomush@<new-ip> docker compose -f /opt/holomush/compose.yaml ps.