Start here
Quickstart
Add a site, point it at an origin, block something, and read the trace that proves it.
This page takes one real domain from nothing to serving. It uses the HTTP API rather than the dashboard, so that every step is explicit and scriptable — sites, DNS records, origin pools and firewall rules all have screens on the zone page if you would rather click. Analytics, traces and cache purge do not have screens yet. Every field name and default below is copied from the route handler that reads it.
- Sign in, or sign up — the same account as guardyn.dev.
- Add the zone, and read the nameservers it hands back.
- Create the DNS records, marking the ones you want proxied.
- Create an origin pool, put an origin in it, make it the zone's default.
- Add one firewall rule.
- Publish, with
gdn apply. - Only then change the nameservers at your registrar.
Step 7 being last is the thing worth reading this page for. A zone is fully editable and fully servable from the moment you create it, so steps 2 to 6 all happen while the domain's public DNS still points at your old provider.
One account, two products
Sign-up writes to guardyn.dev's public.users and returns the guardyn_session cookie both products read. There is no linking step, because there are not two accounts to link.
{
"name": "Ahmed",
"email": "you@example.com",
"password": "…",
"org_name": "Amthal", // optional; a workspace is created either way
"invite_token": null // optional; joins an existing workspace instead
}The response is the same body as GET /v1/auth/me: user, org with its role and resolved limits, the workspaces you belong to, can_edit. With an existing guardyn.dev account use POST /v1/auth/login with email, password and an optional totp instead; gdn login does the same from a terminal.
Add the zone
The field takes whatever you paste. normalise_domain strips a scheme, a path, credentials, a port and a trailing dot before validating, so https://Amthalgroup.com/pricing and amthalgroup.com. both become amthalgroup.com. Anything without a dot is rejected with a message naming the apex, because a subdomain is not a zone.
{ "name": "https://Amthalgroup.com/" }
→ 200
{
"zone": {
"id": "0199f0c8-6b5f-7c11-9f2e-4a1d3c7b9e80",
"name": "amthalgroup.com",
"unicode_name": null,
"status": "pending_ns",
"plan_id": "free",
"proxying": false,
"delegated": false,
"dnssec": false,
"record_count": 0,
"created_at": "2026-08-27T09:12:44.108Z",
"activated_at": null
},
"next_step": {
"action": "change_nameservers",
"nameservers": ["ns1.guardyn.net", "ns2.guardyn.net"],
"message": "At your registrar, replace amthalgroup.com's nameservers with ns1.guardyn.net, ns2.guardyn.net. We check every few minutes and activate the zone automatically."
}
}next_step ships with the zone rather than behind a second call, so the dashboard can show the registrar instruction on the render that created the site. The names come from GUARDYN_NAMESERVERS, default to ns1.guardyn.net and ns2.guardyn.net, and are stored on the zone — so changing the platform's nameserver pool later cannot silently change what an existing customer was told to configure.
Ceilings are checked against the count that would result, because passing the current count is how an off-by-one lets one extra through. On free that is three zones; the fourth gets 402, code limit_reached, and zones: 3 of 3 used on the free plan.
Why pending_ns is a working state
A new zone is pending_ns: created, editable, and not yet reachable by name. It is not a queue you wait in. Three places in the codebase agree to treat it as live configuration:
ZoneStatus::serves_http()returns true forPendingNs.- the snapshot builder's zone query selects
status IN ('pending_ns', 'active', 'paused'). ConfigSnapshot::reindex()always puts the zone apex in the hostname index, whether or not a certificate or a record exists yet.
So after your first apply, resolve the name yourself. Anything other than the platform's own 404 means the zone is in the snapshot the node is serving:
# --resolve sets both the Host header and the SNI name. curl -skI --resolve amthalgroup.com:8443:127.0.0.1 https://amthalgroup.com/ # A hostname the node does not know answers with a 404 that says so: # web.guardyn.dev is not configured to serve amthalgroup.com. # If this is your domain, add it and point its nameservers here.
-k is there because a development build issues certificates from a local CA it generates under --data-dir; trust that file and drop the flag. The ports are the node's own defaults — GUARDYN_EDGE_HTTP is 0.0.0.0:8080 and GUARDYN_EDGE_HTTPS is 0.0.0.0:8443 — so substitute whatever your node binds. Plain HTTP answers 301 to the https URL rather than serving, because always_https defaults to on.
DNS records
One endpoint, one body shape, parsed per record type on arrival. content is the single-line form a table row shows, because that is what a person edits — but it becomes a typed payload before it is stored, so a malformed MX is rejected at authoring time rather than by the authoritative server at answer time.
| Field | Meaning |
|---|---|
| name | Relative (www), absolute (www.amthalgroup.com), or @ for the apex. All three resolve through one function, so www.example.com.example.com cannot happen. |
| type | A, AAAA, CNAME, ALIAS, MX, TXT, NS, SRV, CAA, PTR, HTTPS, SVCB, TLSA or SSHFP. Anything else is rejected with that list in the message. |
| content | Single-line. 10 mx1.example.com for an MX, or a bare host plus priority — both work, because both are how UIs do it. |
| ttl | Seconds, or omitted for "auto". Clamped to 30–86400. |
| proxied | Default false. Honoured only on A, AAAA, CNAME and ALIAS. |
| comment | Free text, truncated to 500 characters because it comes from a form. |
{ "name": "www", "type": "A", "content": "203.0.113.7", "proxied": true }
→ 200
{
"record": {
"id": "0199f0c8-9a02-7f43-8bd1-6e5c2f0a7712",
"name": "www.amthalgroup.com",
"type": "A",
"content": "203.0.113.7",
"data": { "type": "A", "address": "203.0.113.7" },
"ttl": "auto",
"effective_ttl": 30,
"proxied": true,
"proxiable": true,
"comment": "",
"managed_by": null,
"updated_at": "2026-08-27T09:14:02.771Z"
}
}effective_ttl came back 30, not the unproxied 300: a proxied record answers with our addresses, and a 24-hour TTL on an address we need to move would pin traffic to a node we are trying to drain. proxied and proxiable are separate because proxying needs the flag and a type that can carry HTTP — the flag on a TXT record is ignored, since honouring it would mean answering a TXT query with an IP address.
Marking a record proxied is also what puts its hostname into the snapshot's host index. An unconfigured www is deliberately not added, so one zone cannot take a hostname from another zone that holds it explicitly.
A pool, an origin, and the zone's default
There is no “set the origin for this zone” endpoint, and there will not be one. A pool balances between interchangeable servers; a load balancer steers between pools. Flattening that would be friendlier for five minutes and would then have to be un-shipped.
The pool
{ "name": "primary" }
// Everything else has a default:
// policy round_robin
// minimum_healthy 1
// max_connections 200
// retry_next_on_failure true
// enabled true
// affinity_key, region null| policy | What it is for |
|---|---|
| round_robin | Weighted round robin. Predictable, and the right default. |
| least_connections | Fewest in-flight requests. Better under uneven response times, worse when a broken origin answers instantly with 500s — health checking is not optional with this one. |
| ip_hash | Hash the visitor’s address, so they stick to one origin. Breaks when the pool changes. |
| key_hash | Hash a cookie or header. Affinity that survives pool changes. Needs an affinity_key. |
| failover | First healthy origin in order. Active/passive. |
| random_two_choices | Random pick of two, take the less loaded. Nearly as good as least-connections with none of the coordination. |
GET /v1/zones/{zone_id}/pools returns that same list under policies, because a picker that has drifted from what the server accepts is a form that fails on submit.
The origin
{ "name": "web-1", "address": "origin.amthalgroup.com" }
→ 200
{
"origin": {
"id": "0199f0c9-1d77-7002-b4aa-90cf1e2b6d55",
"pool_id": "0199f0c8-e410-73a9-8f6b-2c0d5a8e4411",
"name": "web-1",
"address": "origin.amthalgroup.com",
"port": 443,
"authority": "origin.amthalgroup.com:443",
"host_header": null,
"sni": null,
"weight": 1,
"enabled": true,
"in_maintenance": false,
"max_connections": null
}
}A literal IP is stored as an address; anything else stays a hostname and is re-resolved on every connection, which is what lets you move your origin without touching our configuration. A value containing / or a space is refused, because a path belongs in a rewrite rule and not in the origin. port defaults to 443.
Pointing the zone at it
{ "pool_id": "0199f0c8-e410-73a9-8f6b-2c0d5a8e4411" }
→ 200
{ "zone": { "id": "0199f0c8-6b5f-7c11-9f2e-4a1d3c7b9e80",
"default_pool_id": "0199f0c8-e410-73a9-8f6b-2c0d5a8e4411" } }null is accepted and clears it, because that is the state a zone is in before its first pool exists. On free the ceiling is one pool per zone.
One firewall rule
Rules are written and returned as source text, not as a JSON expression tree. POST /v1/rules/check stores nothing and needs no role beyond a session, which is why the editor can call it as somebody types.
{ "expression": "http.request.uri.path starts_with \"/wp-admin\" and ip.geoip.country ne \"om\"" }
→ 200
{
"ok": true,
"normalised": "http.request.uri.path starts_with \"/wp-admin\" and ip.geoip.country ne \"om\"",
"fields": ["http.request.uri.path", "ip.geoip.country"],
"response_phase_only_fields": []
}A failure comes back as { "ok": false } with a message and an annotated string carrying a caret under the offending character — the difference between a rule editor people use and one they avoid. response_phase_only_fields lets the editor warn that a request-phase rule reads a response-phase field before the save fails.
{
"phase": "custom",
"description": "wordpress scanners",
"expression": "http.request.uri.path starts_with \"/wp-admin\"",
"action": "block"
}
→ 200
{
"rule": {
"id": "0199f0ca-4c31-7db8-a0e7-15b3d9c6f204",
"ruleset_id": "0199f0ca-4c30-71f5-9c88-7ad0e4b12299",
"position": 1,
"description": "wordpress scanners",
"expression": "http.request.uri.path starts_with \"/wp-admin\"",
"action": "block",
"action_detail": { "action": "block", "status": 403, "body": "", "content_type": "" },
"enabled": true,
"created_at": "2026-08-27T09:18:31.005Z",
"updated_at": "2026-08-27T09:18:31.005Z"
}
}You never create a ruleset: phase names one of nine — ip_access, managed, custom, rate_limit, bots, redirect, request_transform, cache_rules, response_transform — and the ruleset for it is created on demand. position is 1-based and omitting it appends. The actions here are log, block, challenge, skip and redirect. set_cache and rate_limit carry structured payloads and cannot be created over HTTP at all yet — this endpoint rejects them and no other endpoint accepts them. Only the custom phase counts against custom_rules — five on free.
Publish
Nothing above is being served yet. Every edit is a draft in Postgres until a snapshot is built, signed and activated. GET /v1/config is open to any signed-in member and answers the question people actually ask:
{
"live": { "version": 3, "content_hash": "18e29888782667dc…", "created_by": "ops@amthal",
"message": "block wordpress scanners", "zones": 4, "bytes": 21744,
"key_id": "…", "activated_at": "…", "created_at": "…" },
"has_unpublished_changes": true,
"message": "Your saved configuration differs from what is being served. It goes live on the next apply.",
"workspace": "Amthal"
}Then publish. gdn plan renders a real diff and consumes nothing — looking must be free, or people stop looking — and gdn apply activates exactly one new version.
$ gdn plan
$ gdn apply -m "amthalgroup.com: origin, dns, wp-admin block"
// POST /v1/config/apply { "message": "…", "accept_disruption": false }
// → { "applied": true, "version": 4, "content_hash": "…", "zones": 4, "changes": 3, "rendered": "…" }When nothing changed you get { "applied": false, "reason": "no_changes" } and no version is consumed — taking one and then finding there was nothing to do leaves a hole in a monotonic sequence. When the diff can interrupt traffic you get a 409 carrying the rendered plan and instructions to re-send with accept_disruption.
Now change the nameservers
Only once the zone serves what you expect over a Host header is the registrar change worth making. Replace the domain's nameservers with the two from next_step.nameservers, then ask us to look:
→ 200
{
"zone": { "…": "the full zone detail, including observed_nameservers and ns_checked_at" },
"delegated": false,
"checked_at": null,
"message": "We have not seen a delegation for amthalgroup.com yet. Set its nameservers to ns1.guardyn.net, ns2.guardyn.net at your registrar; changes can take up to 24 hours to propagate."
}When the delegation matches, the zone moves to active and activated_at is stamped. “Matches” means every nameserver we assigned is present at the parent, compared case-insensitively and ignoring a trailing dot because registrars are inconsistent about both. Extra nameservers are tolerated: adding ours alongside the old ones is a valid intermediate state during a cutover, and failing it would force an all-at-once switch.
Going active is also what turns proxying on — status.proxies() is true for Active alone. If the proxy then breaks something, PUT /v1/zones/{zone_id}/status with { "status": "paused" } answers DNS with your origin's real address so traffic bypasses us entirely, without losing anything you configured above.
If you would rather not involve a registrar
Everything except the last section works without owning a domain, because everything except the last section works over a Host header. Run it locally brings up the control plane and a node with nothing published — the shortest path to watching a rule fire and reading the trace that proves it. For the expression language in full, see Firewall rules.