Getting startedGuidesReferenceChangelog
Apoxy:// Docs / Guides / Custom domains

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

$domain.yamlYAML
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: default

Apply the DomainRecord, then create the CNAME at your registrar:

$terminalTXT
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:

$terminalTXT
_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.

HostnameCorrect targetCommon 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:

$terminalSH
apoxy domainrecord get api.example.com -o yaml

You should see:

$terminalYAML
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: CNAMENotConfigured

TLSReady=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:

$terminalTXT
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:

$terminalYAML
spec: tls: disabled: true

disabled: 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 Active once 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:

$terminalTXT
_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.

PhaseMeaningRoutable
PendingNo ownership proof observed yet: no main CNAME, no challenge delegation.No
IssuingOwnership proven, certificate being minted (first issuance, or re-issuance after an expiry). Routable so the HTTP-01 challenge can be answered.Yes
ActiveEverything on Apoxy's side is done: ownership proven, certificate stored (for TLS records). Traffic serves the moment it arrives.Yes
DetachedEvery 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 Active with CNAMEConfigured=False - our side is finished; the remaining step is yours.
  • Backend health. An Active record with an unhealthy backend stays Active and serves 502s, reported by the TargetReady condition.

Records created before phases existed show an empty phase until their next reconcile.

Condition reference

ConditionMeaning
OwnershipVerifiedTrue 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.
TLSReadyA 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.
ACMEChallengeReadyYour _acme-challenge.<fqdn> CNAME points into our managed zone, so we can pre-issue certs via DNS-01. Also absent when tls.disabled: true.
CNAMEConfiguredYour 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.
TargetReadyThe backend (Gateway / Proxy / Tunnel / EdgeFunction) this record points at is healthy.
ReadyAggregate: 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-challenge delegation and OwnershipVerified will go False with reason OwnershipNotVerified, 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=False after adding the CNAME - DNS caching. Wait up to the record's TTL at the customer resolver, then re-check with dig CNAME _acme-challenge.api.example.com.

  • TLSReady=False with reason CertificateError - 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 yaml shows 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 yaml should flip ACMEChallengeReady=True on the next reconcile (runs every ~30s).

  • ACME rate-limit errors - issuance workflows honor the CA's Retry-After header 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; check apoxy domainrecord get <name> - TLSReady will flip to True without further action.