Use when deploying this repository to either a fresh Linode VM or a prepared Linode instance and you need the canonical Shuma bootstrap and production bring-up path.
Use the repository-native one-shot deployment path:
make deploy-env-validate).HEAD.--existing-instance-id.make setup-runtime, make deploy-self-hosted-minimal, make smoke-single-host, make prod-start, make stop) without introducing a parallel pipeline.
make smoke-single-host now includes forwarded public-path parity against the upstream origin plus reserved-route/admin checks.--open-dashboard for that finish line.Production posture is gateway-only (client -> shuma -> existing origin). This path is for existing-site protection, not in-app front-door hosting.
If you are starting from a local site plus a Linode account rather than an already-prepared upstream, use ../prepare-shared-host-on-linode/SKILL.md first. That setup skill is agent-facing: it captures or validates LINODE_TOKEN, proposes SHUMA_ADMIN_IP_ALLOWLIST, generates GATEWAY_SURFACE_CATALOG_PATH, writes .shuma/linode-shared-host-setup.json, emits the normalized day-2 remote receipt under .shuma/remotes/<name>.json, and auto-selects that remote in .env.local.
If the operator also wants the full shared-host Scrapling runtime active, this deploy skill now depends on ../prepare-scrapling-for-deploy/SKILL.md. In the normal path the deploy helper runs that automation itself; do not ask the operator to hand-author scope, seed, or ADVERSARY_SIM_SCRAPLING_* env values.
Live proof reference:
Do not provision anything until all required inputs are known and validated.
Required:
LINODE_TOKEN: Linode Personal Access Token with Linodes read/write scope.SHUMA_ADMIN_IP_ALLOWLIST: trusted admin IP/CIDR list.SHUMA_GATEWAY_UPSTREAM_ORIGIN: existing origin in scheme://host[:port] form.SHUMA_GATEWAY_DEPLOYMENT_PROFILE=shared-server.SHUMA_GATEWAY_ORIGIN_LOCK_CONFIRMED=true.SHUMA_GATEWAY_RESERVED_ROUTE_COLLISION_CHECK_PASSED=true after clean preflight.SHUMA_GATEWAY_TLS_STRICT=true.SHUMA_ADMIN_EDGE_RATE_LIMITS_CONFIRMED=true.SHUMA_ADMIN_API_KEY_ROTATION_CONFIRMED=true.GATEWAY_SURFACE_CATALOG_PATH: discovered origin public-surface catalog JSON.--domain <fqdn>: canonical production path requires domain/TLS from the start.Important boundary:
GATEWAY_SURFACE_CATALOG_PATH remains a gateway preflight and smoke artifact.--domain and the canonical public base URL.These may already be satisfied by the setup helper:
LINODE_TOKEN from .env.localSHUMA_ADMIN_IP_ALLOWLIST from .env.localGATEWAY_SURFACE_CATALOG_PATH from .env.local--existing-instance-id <linode-id> from .shuma/linode-shared-host-setup.jsonPrepared same-host rule:
http://127.0.0.1:8080, the origin service must already be real before calling this path,--existing-instance-id <linode-id> so Shuma attaches to it without reprovisioning drift.Recommended:
--profile <small|medium|large> to pick a default host size.--region, --label to control host placement and naming.--type only when overriding profile defaults.Preflight-only validation:
LINODE_TOKEN=<token> \
SHUMA_ADMIN_IP_ALLOWLIST=<ip-or-cidr-list> \
SHUMA_GATEWAY_UPSTREAM_ORIGIN=https://origin.example.com \
SHUMA_GATEWAY_ORIGIN_LOCK_CONFIRMED=true \
SHUMA_GATEWAY_RESERVED_ROUTE_COLLISION_CHECK_PASSED=true \
SHUMA_GATEWAY_TLS_STRICT=true \
SHUMA_ADMIN_EDGE_RATE_LIMITS_CONFIRMED=true \
SHUMA_ADMIN_API_KEY_ROTATION_CONFIRMED=true \
GATEWAY_SURFACE_CATALOG_PATH=/abs/path/to/catalog.json \
make deploy-linode-one-shot DEPLOY_LINODE_ARGS="--profile medium --region gb-lon --preflight-only"
Use this before first live run and whenever changing region/type/profile. If the setup helper already populated .env.local, you only need to supply the still-missing upstream/domain/attestation inputs.
Prepared same-host preflight:
LINODE_TOKEN=<token> \
SHUMA_ADMIN_IP_ALLOWLIST=<ip-or-cidr-list> \
SHUMA_GATEWAY_UPSTREAM_ORIGIN=http://127.0.0.1:8080 \
SHUMA_GATEWAY_ORIGIN_LOCK_CONFIRMED=true \
SHUMA_GATEWAY_RESERVED_ROUTE_COLLISION_CHECK_PASSED=true \
SHUMA_GATEWAY_TLS_STRICT=true \
SHUMA_ADMIN_EDGE_RATE_LIMITS_CONFIRMED=true \
SHUMA_ADMIN_API_KEY_ROTATION_CONFIRMED=true \
GATEWAY_SURFACE_CATALOG_PATH=/abs/path/to/catalog.json \
make deploy-linode-one-shot DEPLOY_LINODE_ARGS="--existing-instance-id 123456 --domain shuma.example.com --preflight-only"
Gateway preflight is mandatory before cutover:
make deploy-env-validate
This enforces gateway contract alignment, reserved-route collision preflight, and production lock attestations.
Default profile mappings in this repository:
| Profile | Default Linode type |
|---|---|
small | g6-nanode-1 |
medium | g6-standard-1 |
large | g6-standard-2 |
If you need a different plan, override with --type <linode-type>.
Run from repository root:
LINODE_TOKEN=<token> \
SHUMA_ADMIN_IP_ALLOWLIST=<ip-or-cidr-list> \
SHUMA_GATEWAY_UPSTREAM_ORIGIN=https://origin.example.com \
SHUMA_GATEWAY_ORIGIN_LOCK_CONFIRMED=true \
SHUMA_GATEWAY_RESERVED_ROUTE_COLLISION_CHECK_PASSED=true \
SHUMA_GATEWAY_TLS_STRICT=true \
SHUMA_ADMIN_EDGE_RATE_LIMITS_CONFIRMED=true \
SHUMA_ADMIN_API_KEY_ROTATION_CONFIRMED=true \
GATEWAY_SURFACE_CATALOG_PATH=/abs/path/to/catalog.json \
make deploy-linode-one-shot DEPLOY_LINODE_ARGS="--domain shuma.example.com --region gb-lon --profile medium"
Prepared same-host handoff:
LINODE_TOKEN=<token> \
SHUMA_ADMIN_IP_ALLOWLIST=<ip-or-cidr-list> \
SHUMA_GATEWAY_UPSTREAM_ORIGIN=http://127.0.0.1:8080 \
SHUMA_GATEWAY_ORIGIN_LOCK_CONFIRMED=true \
SHUMA_GATEWAY_RESERVED_ROUTE_COLLISION_CHECK_PASSED=true \
SHUMA_GATEWAY_TLS_STRICT=true \
SHUMA_ADMIN_EDGE_RATE_LIMITS_CONFIRMED=true \
SHUMA_ADMIN_API_KEY_ROTATION_CONFIRMED=true \
GATEWAY_SURFACE_CATALOG_PATH=/abs/path/to/catalog.json \
make deploy-linode-one-shot DEPLOY_LINODE_ARGS="--existing-instance-id 123456 --domain shuma.example.com"
Interactive finish line:
make deploy-linode-one-shot DEPLOY_LINODE_ARGS="--existing-instance-id 123456 --domain shuma.example.com --open-dashboard"
If you want the normalized day-2 remote receipt to use a stable friendly name instead of the domain-derived default, add --remote-name <name> to the deploy args. Otherwise the successful deploy will auto-select the default name it derived locally.
The 2026-03-06 live proof used this pattern successfully:
make prepare-linode-shared-host,http://127.0.0.1:8080,--existing-instance-id,make smoke-single-host derive the admin-route forwarded IP from SHUMA_ADMIN_IP_ALLOWLIST,SHUMA_SMOKE_FORWARD_PATH must be overridden explicitly.If the origin ever logs paths that start with /http://..., the host is running a pre-05a0376 build and must be redeployed.
make deploy-env-validate.
It does this against a rendered Spin manifest rather than mutating the repo spin.toml.HEAD.--existing-instance-id..env.local, the release bundle, the reserved-route surface catalog, and bootstrap scripts.
It also generates the shared-host Scrapling scope/seed receipt from the canonical public base URL, uploads the resulting scope and seed artifacts, and writes the ADVERSARY_SIM_SCRAPLING_* env contract into the deployed overlay automatically.systemd unit for persistent runtime.
The runtime uses SHUMA_SPIN_MANIFEST=/opt/shuma-gorath/spin.gateway.toml.
The smoke run also derives a public forward-probe path from GATEWAY_SURFACE_CATALOG_PATH; if that path is too dynamic, rerun with SHUMA_SMOKE_FORWARD_PATH=/stable/public/path.
The first cold ready wait now budgets 300 seconds on the remote host because initial Spin component preparation on a fresh instance can exceed the old 90-second default..shuma/remotes/<name>.json and auto-selects it in .env.local so generic make remote-* day-2 operations can take over from provider-specific deploy plumbing.
The normalized receipt now also keeps the optional Scrapling scope/seed metadata so make remote-update preserves the same runtime artifact contract later.make telemetry-shared-host-evidence; a deploy is not fully accepted if the admin telemetry bootstrap path is regressing even when health/auth smoke passes.Scrapling reality rule:
After first successful deploy, routine operations should use the generic remote layer instead of rerunning Linode-specific setup logic:
make remote-update
make remote-status
make remote-logs
make remote-start
make remote-stop
make remote-open-dashboard
The successful deploy already selected the emitted remote locally and persists any generated operator secrets needed for dashboard/admin/smoke access into local .env.local. Use make remote-use REMOTE=<name> later only when you want to switch targets. remote-update now ships the exact committed local HEAD, preserves the remote .env.local and .spin, runs a remote loopback /health check plus public-route smoke, refreshes the receipt metadata, and attempts rollback if smoke fails. If an older host is missing smoke-critical secrets locally, the helper hydrates them from the remote .env.local first. It must use the shipped prebuilt bundle on-host and must not rebuild dashboard/runtime artifacts remotely.
If the remote receipt includes Scrapling metadata, remote-update also re-uploads the scope and seed artifacts and restores the same ADVERSARY_SIM_SCRAPLING_* runtime contract automatically.
Fresh-proof hitch notes:
Running remote bootstrap and the final smoke output can still be normal; the expensive segment is remote package/runtime/build work, not a silent no-op.sslip.io; if --open-dashboard lands on a filter/captive page locally, verify from the remote host itself, from an unfiltered network, or with a real domain rather than assuming Shuma failed.Cutover sequence:
make deploy-env-validate.make test-gateway-profile-shared-server and make smoke-gateway-mode.Rollback sequence:
After deployment, run:
ssh -i <private-key> shuma@<server-ip> 'sudo systemctl status shuma-gorath --no-pager'
ssh -i <private-key> shuma@<server-ip> 'sudo journalctl -u shuma-gorath -n 200 --no-pager'
ssh -i <private-key> shuma@<server-ip> 'cat /opt/shuma-gorath/.shuma-release.json'
If --domain was used and TLS is not active yet, confirm DNS points to the new server IP and restart Caddy.
For troubleshooting and cleanup procedures, use: