Custom domains
Attach a customer-owned domain to Apoxy's default Gateway with zero-downtime TLS provisioning.
A DomainRecord binds a hostname you own (e.g., api.example.com) to a Gateway, Proxy,
TunnelNode, or EdgeFunction on Apoxy. Apoxy handles DNS delegation, ACME certificate issuance, and
renewal.
How it works
For customer-owned hostnames, Apoxy uses a CNAME model. You keep authority over your zone;
Apoxy only needs you to point the hostname at a zone we control (cname.apoxy.net) so we can
serve traffic and issue certificates on your behalf.
Minimal setup
apiVersion: core.apoxy.dev/v1alpha3
kind: DomainRecord
metadata:
name: api.example.com
spec:
name: api.example.com
tls: {}
target:
ref:
group: gateway.apoxy.dev
kind: Gateway
name: defaultApply the DomainRecord, then create the CNAME at your registrar:
api.example.com. CNAME api.example.com.cname.apoxy.net.Apoxy finds your project-scoped target via apoxy domainrecord get <name> -o yaml - the
controller puts the exact value into status.
Once the CNAME propagates, Apoxy issues a TLS certificate using the ACME HTTP-01 challenge (Let's Encrypt / Google Trust Services). This takes roughly 30–60 seconds.
Zero-downtime migration (pre-issuance)
The default HTTP-01 flow has a side effect: the certificate is only issued after you flip
your main CNAME. Until status.conditions.TLSReady=True, browsers hitting the new endpoint see
a certificate error - typically a 30–60 second window.
To eliminate that window, delegate the ACME validation to Apoxy before you cut over:
Step 1 - apply the DomainRecord
Same as above.
Step 2 - add the ACME challenge delegation CNAME
At your registrar, create an additional CNAME:
_acme-challenge.api.example.com. CNAME _acme-challenge.api.example.com.cname.apoxy.net.The target must be _acme-challenge.<your-full-domain>.cname.apoxy.net. - include every
label of your hostname verbatim, including the TLD. Dropping the TLD or a mid-label is the
most common mistake and the delegation check will never pass.
| Hostname | Correct target | Common wrong target |
|---|---|---|
api.example.com | _acme-challenge.api.example.com.cname.apoxy.net. | _acme-challenge.api.example.cname.apoxy.net. (missing .com) |
app.example.co.uk | _acme-challenge.app.example.co.uk.cname.apoxy.net. | _acme-challenge.app.example.co.cname.apoxy.net. (missing .uk) |
If you want the exact string without typing it yourself, apoxy domainrecord get <name> -o yaml prints the expected target in the ACMEChallengeReady condition message.
This tells the ACME CA to look up the validation TXT record inside our zone, so we can prove domain ownership via DNS-01 without any traffic flowing through Apoxy yet.
Step 3 - wait for the certificate
Apoxy detects the delegation and pre-issues the certificate. Watch the conditions:
apoxy domainrecord get api.example.com -o yamlYou should see:
status:
conditions:
- type: ACMEChallengeReady
status: "True"
reason: ACMEChallengeConfigured
- type: TLSReady
status: "True"
reason: CertificateIssued
- type: CNAMEConfigured
status: "False" # main record not flipped yet - expected
reason: CNAMENotConfiguredTLSReady=True will land before CNAMEConfigured=True. At that point the certificate is
sitting in our gateway, ready for traffic.
Step 4 - flip the main CNAME
Now change your main record at your registrar:
api.example.com. CNAME api.example.com.cname.apoxy.net.The next DNS resolution hits Apoxy. Our gateway serves the already-issued certificate from the first request - no cert error, no refresh loop.
Step 5 - keep the delegation CNAME
Leave _acme-challenge.api.example.com pointing at our zone. Renewal uses the same DNS-01
path, so keeping the delegation means renewals stay zero-intervention.
Zero-downtime migration - HTTP-only (no TLS)
TLS is the default: a DomainRecord that targets a Gateway, Proxy, TunnelNode, EdgeFunction,
or another DomainRecord gets a managed certificate even if you omit the tls block
entirely. To serve a hostname over plain HTTP instead, ask for it explicitly:
spec:
tls:
disabled: truedisabled: true and certificateAuthority are mutually exclusive - naming a CA while
switching issuance off is rejected at admission rather than silently honored one way.
disabled governs whether Apoxy provisions a certificate for a domain that does not have
one. It is not a way to remove a certificate you already have:
- No certificate is provisioned, and the domain settles at
Activeonce ownership is proven rather than waiting on an issuance that will never happen. - A domain that already holds a certificate keeps it, keeps serving it, and keeps renewing it. Letting a certificate that is still being served lapse would break HTTPS on a cliff, so renewal deliberately continues.
- Ownership is unaffected. A domain verified by an existing certificate stays verified and routable - disabling TLS never detaches a live hostname.
- Clearing the field starts issuance on the next reconcile.
To stop serving a certificate entirely, delete the DomainRecord.
Until a certificate exists, HTTPS requests for the hostname fail the TLS handshake; plain HTTP serves normally.
With TLS off there is no ACME certificate whose issuance could double as proof of ownership. Apoxy instead accepts an explicit ownership challenge at a dedicated label:
_apoxy-challenge.api.example.com. CNAME _apoxy-verify.cname.apoxy.net.The target is a single fixed string - no FQDN embedding, no per-domain token. Copy it verbatim.
Once the challenge resolves, the controller marks the DomainRecord with
OwnershipVerified=True and admits the hostname to the data plane. From that point, the
moment you flip your main CNAME the gateway starts serving traffic for it - the same
zero-downtime effect as the TLS flow, without needing a certificate.
TLS customers get OwnershipVerified=True as soon as their main CNAME resolves to our zone -
a CNAME pointing at us proves DNS control on its own, whether or not a certificate has been
issued yet. An issued, unexpired certificate also holds ownership on its own: ACME proved you
controlled the domain when it was issued, and that proof stands until the certificate expires.
A DNS blip - ours or your provider's - can therefore never de-verify a domain that holds a
valid certificate. Renewal requires a fresh DNS proof, so a domain you genuinely move away
detaches when its certificate lapses. Setting _apoxy-challenge in
addition is optional for TLS - the _acme-challenge delegation already covers both the
pre-issued cert and ownership.
You can leave the _apoxy-challenge delegation in place permanently; it's idempotent and
doesn't need to be touched across renewals or cutovers.
Lifecycle phases
status.phase is the single answer to "where is my domain". Conditions (below) are the
observation log underneath it: each records one fact and when it last changed, which is what
explains why the record is in a given phase.
| Phase | Meaning | Routable |
|---|---|---|
Pending | No ownership proof observed yet: no main CNAME, no challenge delegation. | No |
Issuing | Ownership proven, certificate being minted (first issuance, or re-issuance after an expiry). Routable so the HTTP-01 challenge can be answered. | Yes |
Active | Everything on Apoxy's side is done: ownership proven, certificate stored (for TLS records). Traffic serves the moment it arrives. | Yes |
Detached | Every ownership proof is gone after the record was live: all DNS proofs withdrawn and no unexpired certificate held. Releases the hostname. | No |
Phase transitions require affirmative observations: if we cannot reach a resolver or the certificate store, the phase holds rather than regressing. The phase describes Apoxy's side only. Two things it deliberately excludes are reported by conditions instead:
- Cutover. A pre-issued record whose main CNAME has not been flipped yet is
ActivewithCNAMEConfigured=False- our side is finished; the remaining step is yours. - Backend health. An
Activerecord with an unhealthy backend staysActiveand serves 502s, reported by theTargetReadycondition.
Records created before phases existed show an empty phase until their next reconcile.
Condition reference
| Condition | Meaning |
|---|---|
OwnershipVerified | True when Apoxy has proof you control this hostname - a valid _apoxy-challenge CNAME, a successful main-CNAME cutover, an issued unexpired certificate, or - for zone-managed records - ownership of the zone itself (reason: ZoneOwned). This is the fact behind the routable phases. A hostname is provably yours before your first cert is issued, and a valid cert keeps it yours until expiry even through DNS blips. |
TLSReady | A stored, unexpired certificate exists for this hostname. This is what gates HTTPS - until it is True, plain HTTP routes serve but TLS handshakes do not complete. It reflects the certificate only, so an unhealthy backend does not retract it: you get a 502 from the backend rather than a TLS error. Absent entirely on records with tls.disabled: true, which have no certificate lifecycle and go straight to Active once ownership is proven. |
ACMEChallengeReady | Your _acme-challenge.<fqdn> CNAME points into our managed zone, so we can pre-issue certs via DNS-01. Also absent when tls.disabled: true. |
CNAMEConfigured | Your main CNAME currently resolves to our zone. For CNAME-mode records this is one of the proofs that flips OwnershipVerified (above); on its own it does not decide whether traffic is served. |
TargetReady | The backend (Gateway / Proxy / Tunnel / EdgeFunction) this record points at is healthy. |
Ready | Aggregate: all of the customer-visible setup above is green. Dashboards and CLI show this; the data plane does not key on it. |
How conditions behave when we can't read your DNS
Every condition above that is derived from a DNS lookup - OwnershipVerified,
CNAMEConfigured, ACMEChallengeReady - distinguishes two different negatives:
- Your record is not published. A resolver answered and told us nothing is there. The
condition updates to reflect that. Remove your
_apoxy-challengedelegation andOwnershipVerifiedwill goFalsewith reasonOwnershipNotVerified, which also releases your claim on the hostname. - We could not reach a resolver. No answer came back at all, so nothing is known. The condition is held at its last observed value rather than retracted, and the status is not rewritten. A DNS outage on our side or a public-resolver blip therefore does not change what your domain reports, and does not interrupt serving.
The practical consequence: a condition that has not changed in a while is not necessarily a
condition we re-confirmed a moment ago. lastTransitionTime records when the value last
changed, not when it was last checked.
Fallback
If you skip step 2 (no _acme-challenge CNAME), Apoxy falls back to HTTP-01 after you flip
the main CNAME. That still works - we will try to create certificates on the fly and you just
get the standard 30–60s window between cutover and TLSReady=True.
Troubleshooting
-
ACMEChallengeReady=Falseafter adding the CNAME - DNS caching. Wait up to the record's TTL at the customer resolver, then re-check withdig CNAME _acme-challenge.api.example.com. -
TLSReady=Falsewith reasonCertificateError- check the status message. Common causes: delegation CNAME points to the wrong target (verify it ends in.<cname-zone-suffix>.exactly - see the label-match table in Step 2), or the ACME account is rate-limited. -
Want to see what target to use -
apoxy domainrecord get <name> -o yamlshows the full delegation target in the condition message. -
Fixed a typo in the delegation CNAME but it still shows
False- two DNS caches may hold stale answers until they expire:- The old CNAME value at recursive resolvers - pinned for the TTL you set at your registrar (commonly 300s–3600s).
- The NXDOMAIN for the broken target inside
cname.apoxy.net- cached up to the zone's SOA MINIMUM TTL, 300s (5 minutes).
To minimize this window, drop your registrar's CNAME TTL (e.g. to 60s) before you first publish it, so any typo is easy to correct. If you've already been bitten, there's no shortcut - wait out the longer of the two TTLs, then
apoxy domainrecord get <name> -o yamlshould flipACMEChallengeReady=Trueon the next reconcile (runs every ~30s). -
ACME rate-limit errors - issuance workflows honor the CA's
Retry-Afterheader and sleep in-place instead of thrashing, so a single rate-limited domain will not burn the shared quota. A certificate that was in flight during a rate-limit event will resume automatically once the window clears; checkapoxy domainrecord get <name>-TLSReadywill flip toTruewithout further action.