Start here

Run it locally

A working amthalgroup.com on your own machine in one command, with nothing published.

There is one script: ./examples/amthalgroup/demo.sh. It creates a zone, gives it DNS records and an origin pool, writes a firewall rule, registers an edge node, publishes a configuration, starts a test origin and the data plane, and then verifies the result by making real requests through the edge. Everything it does goes through the public /v1 API with curl — there is no SQL, and no fixture loaded behind the product’s back. The script’s own header says why: if a step cannot be done through the API, that is a gap in the API, not something the script should paper over.

It needs curl, python3 (used both for the test origin and as a jq substitute, so the script works on a machine without jq) and nc, which is how it waits for the edge to start listening.

Start the control plane first

The demo talks to a control plane that is already running; it will not start one for you. The first thing up does is GET /v1/health, and if nothing answers within four seconds it prints the exact command to run and stops.

terminal one — the control plane
cd /path/to/web.guardyn.dev
GUARDYN_BOOTSTRAP_IDENTITY=true GUARDYN_API_LISTEN=127.0.0.1:8787 \
  cargo run -q -p guardyn-api

GUARDYN_BOOTSTRAP_IDENTITY=true is what makes the project runnable on its own. guardyn.dev owns public.users, public.sessions and the rest of the identity schema; with this flag set, the control plane creates those tables if they are absent, so you do not need the other product’s database to sign in here. Every statement is IF NOT EXISTS, so pointing at a real guardyn.dev database is a no-op. It is deliberately not a migration, and in a release build it refuses to act unless GUARDYN_BOOTSTRAP_IDENTITY_FORCE is also set — unconditional DDL against a fresh production database could create a partial public.users before guardyn.dev’s own migrations ran, leaving the shared database half-built by the wrong owner.

With DATABASE_URL unset, the control plane downloads a real Postgres once, caches it under ~/.theseus, and runs it against .data/pg on port 5433 — not 5432, because colliding with an already-installed Postgres is the worst possible first run. The data persists between runs, so the zone you created with cargo run is still there the second time.

You also need an account to sign in as. The demo does not create one; if the login fails it tells you to sign up at http://localhost:3001/signup, which means the dashboard needs to be running too (npm --prefix web run dev).

One writer for the local database

Two Postgres servers pointed at one data directory will corrupt it, and in practice that happens exactly one way: two terminals both running the control plane. So the first process writes its pid to .data/pg.lock and a second one refuses to start, with a message naming the pid to stop. A lock left behind by a crashed process is reclaimed automatically — the check is kill -0 on the recorded pid, so a dead holder does not need manual cleanup.

Three of the demo’s calls are operator-only

This is the step most likely to stop you, and it is worth knowing before you run anything. Registering a node (POST /v1/nodes), moving a zone onto another plan (PUT /v1/zones/{id}/plan) and publishing a configuration (POST /v1/config/apply) all require the Operator extractor, which checks is_platform_admin on your user row. A non-operator gets a 404 shaped exactly like every other missing route, so that the existence of the operator surface is not confirmed to a prober — which also means the demo’s failure reads as “apply failed” rather than “you are not an operator”.

making yourself a platform operator
psql "postgresql://postgres:guardyn-local@127.0.0.1:5433/guardyn" -c \
  "UPDATE public.users SET is_platform_admin = true WHERE email_normalised = 'you@example.com'"

What up does, in order

  1. Preflight. GET /v1/health, then delete the cookie jar and POST /v1/auth/login. A response without user.id stops the run.
  2. Zone. Looks for amthalgroup.com in GET /v1/zones and creates it if absent, then reads it back — a zone that was created but is not found on read-back is treated as a failure, not a success.
  3. DNS. Six records. @ and www proxied, lab proxied at 127.0.0.1, and then mail, an MX and an SPF TXT as DNS-only records so the authoritative server has something unproxied to serve.
  4. Origin pool. A pool named local-lab (round_robin, minimum_healthy: 1, region local), one origin lab-origin at 127.0.0.1:9099 with host_header set to lab.amthalgroup.com, then PUT …/default-pool.
  5. Plan. Moves the zone to enterprise. See below.
  6. Settings. Reads the zone’s current settings and patches only four fields, so it stays correct as ZoneSettings grows.
  7. Firewall rule. A custom-phase rule blocking /wp-admin with a 403 and a short HTML body, unless a rule mentioning wp-admin already exists.
  8. Node. Registers a node called lab1 and writes its bearer token to .run/node-token at mode 600. If the node exists but the token file is missing, the token is rotated rather than guessed at — it is shown once by design.
  9. Publish. POST /v1/config/apply.
  10. Origin. Starts origin.py, then polls /health ten times at 300ms. On failure it prints the last five lines of origin.log before giving up.
  11. Edge. Reads the config-signing public key from gdn keys show, passes it as GUARDYN_CONFIG_PUBLIC_KEYS, and starts guardyn-edge. Then waits up to thirty seconds for something to accept a connection on the HTTP port, printing the last twenty lines of edge.log if it never does.
  12. Hosts, then verify. Prints the /etc/hosts line if it is not already installed, runs the checks, and prints the URLs to try.

Why the demo changes the plan

Trace sampling is clamped by the zone’s plan. free sets trace_sample_max_bp to 100 — one per cent of requests — so a handful of demo requests on the free plan produces a trace approximately never, and the whole point is to read the traces. enterprise allows 10000 basis points, every request. The script moves the zone rather than bypassing the ceiling, which is the honest way round: the clamp is doing its job, and this is an operator action. If the plan change fails you get a warning rather than a stopped run, and traces stay sampled at one per cent.

The four settings it patches, and why

FieldSet toBecause
origin_tls"off"The test origin speaks plain HTTP, so the edge must not attempt TLS to it.
always_httpsfalseIt defaults to true, so without this the first thing anyone tries — http://lab.amthalgroup.com:8080 — answers 301 to https and appears broken.
trace_sample_bp10000The default is 100 — one per cent. Ten thousand basis points is every request, which is the whole point of the demo.
http3falseHTTP/3 needs QUIC, which this build does not serve. Setting it false is cosmetic: nothing in the data plane reads http3 and no Alt-Svc header is ever emitted, so it changes no served behaviour today.

Why lab.amthalgroup.com and not the apex

The zone is amthalgroup.com, so DNS records, SNI and Host-header behaviour are all exercised against a real domain. The hostname the demo actually asks for is lab.amthalgroup.com, and that choice is not cosmetic: the apex is a live site. Pointing amthalgroup.com at 127.0.0.1 in /etc/hosts would make the real one unreachable from your machine until you remembered to remove the line — and you would remember it while wondering why the site was down. lab.amthalgroup.com does not exist in public DNS, so a hosts entry for it shadows nothing.

the line, and where it comes from
# web.guardyn.dev amthalgroup demo
127.0.0.1 lab.amthalgroup.com

./demo.sh hosts prints the line and the sudo command to append it; ./demo.sh hosts --apply writes it, preceded by that marker comment so down can find and remove its own block later. If you would rather not touch /etc/hosts at all, you never have to — every request the checks make uses curl --resolve, which sets the name for that one request only:

no hosts entry needed
curl -i --resolve lab.amthalgroup.com:8080:127.0.0.1 \
  http://lab.amthalgroup.com:8080/

The six checks

test_all runs against whatever is currently listening, so ./demo.sh test re-runs it without touching your configuration. Four of the six checks are hard failures that make the script report some checks failed; two are warnings, because both have an innocent local explanation.

CheckPasses whenA failure means
GET /Status is 200. Prints the guardyn-cache-status and the first twelve characters of guardyn-ray.The basic path is broken: no config, the wrong host, or the origin unreachable. On failure the first twelve response header lines are printed, which is usually enough.
GET /assets/style.cssThe second request returns guardyn-cache-status: HIT.A warning, not a failure. The asset is served with max-age=31536000, immutable, so anything other than a HIT means the cache did not store it — check whether the response carried Set-Cookie, or a Vary: * that makes an entry non-reusable.
GET /api/nowTwo requests return different now timestamps.A hard failure, and the serious one: the origin sends Cache-Control: no-store and the edge served it from cache anyway. That is a correctness bug, not a tuning problem.
GET /wp-admin/Status is 403.The firewall rule did not fire — the rule is not in the published snapshot, or the expression did not match. The origin answers 200 here on purpose, so a 403 can only have come from the edge.
GET / on an unknown hostnot-a-zone.example gets 404, 403 or 421.The edge served a host it has no zone for, which is an open proxy. In this build the answer is a 404 with a body explaining that this hostname is not configured — an unknown host is our error to explain, not a 502 that sends someone to inspect a working origin.
GET / over TLSStatus is 200 on the HTTPS port, with curl -k.A warning, not a failure: the local CA at .data/ca/ca.pem is not in your trust store, which is expected until you trust that file. Since curl is already passing -k, a non-200 means something worse — SNI did not resolve to a zone, or the CA never issued a certificate for the name.

After the six, the script reads GET /hits on the origin directly and prints origin_requests_served. That counter is the ground truth for everything above: it is incremented by the origin, so it only moves when the edge actually forwarded a request. A cache HIT that did not touch the origin is a claim you can check rather than a header you have to believe.

Sub-commands

CommandWhat it does
demo.sh upThe default with no argument. Everything above, in order, ending with the checks and the list of URLs to try. Safe to re-run: each object is looked up by name before it would be created, the settings patch is idempotent, and an apply with nothing to publish takes no version number.
demo.sh testRe-runs the six checks against whatever is running. Preflight is attempted quietly and its failure ignored, so this works without a usable session.
demo.sh hostsPrints the /etc/hosts line and the command to install it.
demo.sh hosts --applyAppends the marker and the line via sudo tee.
demo.sh statusWhether the origin and the edge are running (with pids), whether /etc/hosts has the entry, and whether the control plane answers.
demo.sh downStops the edge and the origin by pid and removes the hosts block. The zone, its records, the rule and the published configuration are deliberately left in place — delete them in the dashboard if you want.

Environment overrides

VariableDefaultWhat it changes
GUARDYN_API_URLhttp://127.0.0.1:8787The control plane the script drives, and the one the edge is told to pull from.
GUARDYN_DEMO_EMAILa personal address hardcoded in the scriptThe account to sign in as. Set this to your own; the default will not exist in your database.
GUARDYN_DEMO_PASSWORDguardyn-local-devIts password.
GUARDYN_DEMO_ORIGIN_PORT9099The port origin.py listens on, and the port stored on the lab-origin.
GUARDYN_DEMO_EDGE_HTTP8080The edge’s plain HTTP port.
GUARDYN_DEMO_EDGE_HTTPS8443The edge’s HTTPS port.
RUST_LOGinfoPassed through to the edge process only.

The ports are threaded all the way through: changing GUARDYN_DEMO_ORIGIN_PORT changes the origin process, the stored origin address and the /hits read, so the demo stays consistent rather than half-moved.

What it leaves under .run/

Everything the script needs to remember lives in examples/amthalgroup/.run/, next to the script rather than in a temporary directory, so it survives a reboot and you can read it.

FileContents
edge.logThe data plane’s stdout and stderr. One line per request.
origin.logThe test origin’s log: method, path, the Host header it received and the X-Forwarded-For.
edge.pid / origin.pidPids, used by status and down.
node-tokenThe bearer token for node lab1, mode 600. The API shows a node token once; this file is the only copy.
cookies.txtThe curl cookie jar holding the session. Deleted and recreated on every preflight.

Reading the edge log

The line the edge writes per request is the shortest honest summary of what it decided. This is real output from a completed run:

examples/amthalgroup/.run/edge.log
INFO first configuration installed version=4 zones=1
INFO serving http=127.0.0.1:8080 https=127.0.0.1:8443 zones=1 version=4
INFO 200 GET lab.amthalgroup.com /                 XX cache=MISS-STORED 3.9ms
INFO 200 GET lab.amthalgroup.com /assets/style.css XX cache=MISS-STORED 2.2ms
INFO 200 GET lab.amthalgroup.com /assets/style.css XX cache=HIT          0.1ms
INFO 200 GET lab.amthalgroup.com /api/now          XX cache=DYNAMIC      1.0ms
INFO 403 GET lab.amthalgroup.com /wp-admin/        XX cache=-            0.0ms rule=custom/1

MISS-STORED means the edge went to the origin and kept the answer — still a miss to the visitor, but distinguished from a plain MISS so that fill rate is visible. DYNAMIC means nothing in your configuration forbade caching and the response was not cacheable on its own terms, which for /api/now is no-store doing its job; the separate BYPASS exists so that “your config said no” and “your origin said no” are never the same word. The rule=custom/1 on the last line is the rule that blocked it, named the same way it appears in a plan diff. For what every phase of that decision records, see Traces.

The test origin is not a mock

origin.py is a real HTTP server whose only job is to make the edge’s behaviour visible, which a plain static file server cannot do. Every response carries X-Origin-Served-At and X-Origin-Hit-Number, so a response can be attributed to a specific origin visit even when the edge’s own headers are absent.

  • / and /about — cacheable HTML, max-age=60.
  • /assets/*max-age=31536000, immutable.
  • /api/nowno-store JSON. If it is ever served from cache the timestamp stops moving, and check three says so.
  • /hits — the origin’s own counter.
  • /slow — sleeps two seconds, so the value of caching is measurable rather than asserted.
  • /wp-admin/ — returns 200, deliberately. A 404 here would make the firewall check meaningless.
  • /set-cookie — sends Set-Cookie alongside max-age=300, which must suppress caching.

/slow and /set-cookie are not covered by the six checks; try them by hand once the demo is up. The second is the interesting one, because a cache that stores a response carrying Set-Cookie serves one visitor’s session to the next.