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.
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”.
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
- Preflight.
GET /v1/health, then delete the cookie jar andPOST /v1/auth/login. A response withoutuser.idstops the run. - Zone. Looks for
amthalgroup.cominGET /v1/zonesand 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. - DNS. Six records.
@andwwwproxied,labproxied at127.0.0.1, and thenmail, anMXand anSPF TXTas DNS-only records so the authoritative server has something unproxied to serve. - Origin pool. A pool named
local-lab(round_robin,minimum_healthy: 1, regionlocal), one originlab-originat127.0.0.1:9099withhost_headerset tolab.amthalgroup.com, thenPUT …/default-pool. - Plan. Moves the zone to
enterprise. See below. - Settings. Reads the zone’s current settings and patches only four fields, so it stays correct as
ZoneSettingsgrows. - Firewall rule. A
custom-phase rule blocking/wp-adminwith a403and a short HTML body, unless a rule mentioningwp-adminalready exists. - Node. Registers a node called
lab1and writes its bearer token to.run/node-tokenat mode600. If the node exists but the token file is missing, the token is rotated rather than guessed at — it is shown once by design. - Publish.
POST /v1/config/apply. - Origin. Starts
origin.py, then polls/healthten times at 300ms. On failure it prints the last five lines oforigin.logbefore giving up. - Edge. Reads the config-signing public key from
gdn keys show, passes it asGUARDYN_CONFIG_PUBLIC_KEYS, and startsguardyn-edge. Then waits up to thirty seconds for something to accept a connection on the HTTP port, printing the last twenty lines ofedge.logif it never does. - Hosts, then verify. Prints the
/etc/hostsline 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
| Field | Set to | Because |
|---|---|---|
| origin_tls | "off" | The test origin speaks plain HTTP, so the edge must not attempt TLS to it. |
| always_https | false | It 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_bp | 10000 | The default is 100 — one per cent. Ten thousand basis points is every request, which is the whole point of the demo. |
| http3 | false | HTTP/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.
# 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:
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.
| Check | Passes when | A 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.css | The 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/now | Two 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 host | not-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 TLS | Status 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
| Command | What it does |
|---|---|
| demo.sh up | The 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 test | Re-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 hosts | Prints the /etc/hosts line and the command to install it. |
| demo.sh hosts --apply | Appends the marker and the line via sudo tee. |
| demo.sh status | Whether the origin and the edge are running (with pids), whether /etc/hosts has the entry, and whether the control plane answers. |
| demo.sh down | Stops 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
| Variable | Default | What it changes |
|---|---|---|
| GUARDYN_API_URL | http://127.0.0.1:8787 | The control plane the script drives, and the one the edge is told to pull from. |
| GUARDYN_DEMO_EMAIL | a personal address hardcoded in the script | The account to sign in as. Set this to your own; the default will not exist in your database. |
| GUARDYN_DEMO_PASSWORD | guardyn-local-dev | Its password. |
| GUARDYN_DEMO_ORIGIN_PORT | 9099 | The port origin.py listens on, and the port stored on the lab-origin. |
| GUARDYN_DEMO_EDGE_HTTP | 8080 | The edge’s plain HTTP port. |
| GUARDYN_DEMO_EDGE_HTTPS | 8443 | The edge’s HTTPS port. |
| RUST_LOG | info | Passed 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.
| File | Contents |
|---|---|
| edge.log | The data plane’s stdout and stderr. One line per request. |
| origin.log | The test origin’s log: method, path, the Host header it received and the X-Forwarded-For. |
| edge.pid / origin.pid | Pids, used by status and down. |
| node-token | The bearer token for node lab1, mode 600. The API shows a node token once; this file is the only copy. |
| cookies.txt | The 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:
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/now—no-storeJSON. 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/— returns200, deliberately. A404here would make the firewall check meaningless./set-cookie— sendsSet-Cookiealongsidemax-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.