Zone & Domain Resource Design
Goals
Refactor "websites" into a Zone management experience keyed by registrable root domains. A Zone like example.com is a stable management boundary; users enter the Zone through a stable ID path to view and maintain its explicitly declared domains, the reverse proxy routes and certificates bound to those domains, and route-level WAF, Pages and other capabilities.
This design replaces the concept, tables, and APIs of managed_domains. The Zone core does not include authoritative DNS record management; to point ZoneDomain A records at edge nodes, use the optional module Cloudflare DNS Pointing.
Scope and Constraints
- Zone root domains are resolved with the Public Suffix List, e.g.
api.example.co.ukbelongs toexample.co.uk. - URLs use IDs: list at
/websites, detail at/websites/:zoneId; domains are not used as URL parameters. - Zone domains must be explicit FQDNs;
*.example.comis not allowed. TLS certificates may still contain wildcard SANs and cover explicit Zone domains. - A Zone domain is associated with at most one reverse proxy route; a route may associate with multiple Zone domains, thus sharing the same upstream, cache, rate limit, WAF and Pages config across Zones.
- The Zone model itself adds no DNS records, edge functions, preview subdomains, or tenant isolation. Creating/updating external DNS A records is handled by the separate Cloudflare pointing module and does not change the Zone / ZoneDomain table responsibilities.
Core Model
erDiagram
ZONES ||--o{ ZONE_DOMAINS : contains
PROXY_ROUTES ||--o{ ZONE_DOMAINS : serves
TLS_CERTIFICATES ||--o{ ZONE_DOMAINS : secures
PROXY_ROUTES ||--o{ WAF_RULE_GROUP_BINDINGS : applies
PAGES_PROJECTS ||--o{ PROXY_ROUTES : backs
ZONES {
uint id PK
string domain UK
}
ZONE_DOMAINS {
uint id PK
uint zone_id
uint proxy_route_id
string domain UK
uint cert_id
}of_zones
Stores the root domain, created time, and updated time. Root domains are globally unique and cannot be modified in place after creation; to change one, create a new Zone and migrate the domains. Before deleting a Zone, all of its Zone domains must be cleared first.
of_zone_domains
Stores zone_id, explicit domain, nullable proxy_route_id, nullable cert_id, and timestamps. domain is globally unique; all relationship fields are indexed but no physical foreign keys are created. proxy_route_id may be null to host historical domains that have a certificate prepared but no reverse proxy configured yet.
of_proxy_routes gradually removes the domain/certificate redundancy columns domain, domains, cert_id, cert_ids, and domain_cert_ids. Routes must no longer specify any TLS certificate; the route name site_name becomes the stable human-readable identifier, and the compiler reads server_name and its cert_id from the associated Zone domains. This gives each explicit domain a single certificate source.
Business and API
New Zone resources in the admin panel:
GET/POST /api/v1/d/zonesGET/POST /api/v1/d/zones/:id/updatePOST /api/v1/d/zones/:id/deletePOST /api/v1/d/zones/:id/domains(list returned via overview)POST /api/v1/d/zones/:id/domains/:domainID/updatePOST /api/v1/d/zones/:id/domains/:domainID/deleteGET /api/v1/d/zones/:id/overview
Reverse proxy route create/update requests switch to zone_domain_ids and no longer submit domains, cert_id, cert_ids, or domain_cert_ids. The server validates domain ownership, global uniqueness, and certificate SAN coverage in a transaction; failures are returned uniformly via response.Abort*. Deleting a Zone domain bound to a route requires unbinding or deleting the route first; deleting a Zone that still has domains must be rejected.
WAF, Pages, upstream, and release versions remain part of proxy_routes. The Zone overview only aggregates the route state associated with its domains and does not copy or redefine those configs.
Frontend Experience
/websites shows only Zone root domains with configured domain count, route count and status, plus search, create, and action menus. Clicking enters /websites/:zoneId.
The detail page includes:
- Overview: domain, route, and valid certificate statistics; domain—route—certificate summary; route-level WAF and Pages summary.
- Domains: a list of explicit FQDNs, certificate selection, and associated routes; wildcard domains are not shown or accepted.
- Routes: routes filtered to the current Zone, linking to existing route details.
- Certificates: certificates actually referenced by the current Zone's domains.
- Settings: Zone notes and a protected delete operation.
When adding a route, select from Zone domains; users can also register domains in the Zone first, then bind a route. The global reverse proxy route entry remains but uses the same Zone domain selector.
Data Migration
This rework ships in two release phases to avoid SQL using a wrong "last two labels" rule for multi-level public suffixes. Operation details: Zone Domain Migration and Release Acceptance.
- Phase 1 DDL: PostgreSQL and SQLite goose create
of_zones/of_zone_domainsat the same version;of_managed_domainsand route redundancy columns are temporarily kept. - Data import (automatic): at Server startup
migrator.Migrate()first applies goose SQL up to202607120002, then automatically imports legacy route domains /managed_domains(registering root domains viapublicsuffixparsing, writingcert_idandproxy_route_id), then continues with the remaining SQL. Conflicts fail startup; fixing and restarting retries idempotently. No manual command needed. - Code switch: control-plane APIs, config snapshots, rendering, and frontend all use Zone domains as the single source; route writes only use
zone_domain_ids. - Phase 2 cleanup: goose SQL
202607130001_drop_legacy_route_domain_columnsdropsof_managed_domainsand theof_proxy_routesredundancy columns. Down only restores an empty dev-DB structure and does not backfill historical data.
Runtime Model Boundaries
- Persistence: domains and certificates exist only in
of_zone_domains;of_proxy_routesonly stores route policy (upstream, cache, rate limit, WAF binding keys, etc.). - Rendering: the config snapshot assembles temporary
Domains/DomainCertIDsin memory for OpenResty rendering and does not write back to the database. - Structure migration only uses
internal/infra/persistence/migrator/goose/{postgres,sqlite}/*.sql; legacy domains are imported automatically at startup, and after phase 2 the old columns no longer exist so it is a no-op.