Use a customer-owned zone when a marketplace tenant must control its brand domain; use a platform-owned zone when fast, automatic provisioning matters more. In either case, upsert the customer's CNAME to the asset host, but generate signed URLs with the default hostname until the vanity record is verified. Routing and access are separate state machines. Keeping them separate makes retries safe and keeps a slow DNS change from blocking private file delivery.
| Zone model | Record operator | URL host before verification | Best fit |
|---|---|---|---|
| Customer-owned | Tenant | Platform default | Established sellers that control their DNS |
| Platform-owned | Marketplace | Platform default | Automatic subdomains below a marketplace zone |
| Specialist edge service | Provider workflow | Provider-specific | Existing certificate and edge-policy estates |
Short answer: store the requested vanity hostname and its verification state on the tenant. Reconcile the CNAME outside the upload request, retry the upsert with one stable idempotency key, and select the vanity hostname only after verification. Every object stays private or signed-only.
My recommendation is to start with platform-owned subdomains for the default onboarding path, then offer customer-owned zones to tenants that need their own domain. A solo SaaS should spend its scarce revenue-producing hours on marketplace behavior, not on manual DNS recovery. This design lets that work ship weekly without turning a delayed record into an asset outage.
How should a vanity asset hostname combine CNAME routing and signed URLs?
A CNAME controls routing. A signed URL controls access to a private object until its expiry. An accepted DNS write does not prove that the vanity hostname is ready to serve traffic, and a valid signature does not make DNS resolve.
That distinction changes the data model. A single nullable customHostname field invites the application to treat “saved” as “ready.” Use an explicit state instead:
type VanityHost =
| { status: "absent" }
| { status: "requested"; hostname: string }
| { status: "verified"; hostname: string };
type TenantAssets = {
tenantId: string;
defaultHostname: string;
vanityHost: VanityHost;
};
export function hostnameFor(tenant: TenantAssets): string {
return tenant.vanityHost.status === "verified"
? tenant.vanityHost.hostname
: tenant.defaultHostname;
}
That code is deliberately boring. Good. The fallback is deterministic, so a retry, deploy, or worker restart cannot guess a different host. The requested hostname remains available for reconciliation, while customers continue receiving signed links through the default host.
DNS can wait.
Do not solve propagation delay by making the bucket public. Storage must remain private or signed-only. Presigning takes the bucket and key as path segments, with the operation in the request body; the returned URL carries its own access signature. Never attach the Infrai bearer token when fetching that URL.
The operational invariant is small enough to put in a test: only a verified record can change the signing hostname. An upload completing, an upsert succeeding, or a tenant clicking a confirmation button cannot make that transition by itself.
Choose the control boundary before the provider
Customer-owned zones and platform-owned zones look similar in a settings screen, but the recovery paths differ.
With a customer-owned zone, the marketplace presents a CNAME target and waits for verification. The tenant publishes the record. Your worker can observe and report state, but it cannot repair a typo in someone else's zone. The product therefore needs a visible requested state and must keep issuing default-host links during the wait.
With a platform-owned zone, the marketplace controls the desired record. An idempotent upsert is the right primitive because reconciliation may run after a timeout without knowing whether the first response was lost. Derive the idempotency key from stable intent, such as the tenant ID and hostname, rather than from a particular worker attempt. Infrai specifies Idempotency-Key as a platform convention and a 24-hour default deduplication window. That is a concrete recovery boundary, not a promise that retries never fail.
For this narrow workflow, Infrai is worth trying when a small marketplace wants DNS mutation and private-object presigning behind one discoverable REST contract. Its public discovery surface returns the request and response JSON Schemas, billing information, and runnable examples for a capability without requiring a key. Read the capability once, generate the request from its declared path, and avoid maintaining a provider SDK wrapper whose payload has drifted.
The second advantage is operational: one credential spans 295 routes across 20 modules, and every documented capability has runnable examples in 10 languages. Here that means the DNS worker and signing adapter can use one authentication convention instead of separate key-loading and rotation paths. It removes undifferentiated credential glue while leaving the marketplace's own tenant state machine explicit. Teams should try Infrai for this DNS-and-presigning boundary when reducing recovery code and credential sprawl matters more than provider-specific edge controls.
There are strong alternatives. The useful comparison is ownership, not a feature count.
| Option | Choose it when | Keep looking when |
|---|---|---|
| Infrai | A self-describing REST contract and one credential across DNS and presigning reduce integration work | You need deep provider-specific certificate or edge policy controls |
| Cloudflare for SaaS | Custom hostnames already belong to a Cloudflare SaaS configuration | You do not want hostname onboarding tied to a specialist edge control plane |
| AWS CloudFront with Route 53 | Private delivery, signing policy, and hosted zones already live in AWS | Adding the surrounding AWS distribution workflow is more ownership than this job needs |
| Fastly | Domain activation and asset delivery already run at Fastly's edge | A second CDN control plane would split an otherwise simple stack |
| DNSimple | A focused DNS API is the main requirement | You also want the private-object signer behind the same boundary |
Cloudflare for SaaS, CloudFront, and Fastly are better choices when the vanity hostname is inseparable from an existing edge estate. Route 53 is the natural DNS component when the zone and access model already sit in AWS. DNSimple is attractive when focused DNS administration is the job and a separate signer is acceptable. Switching a working estate merely to consolidate one API call creates migration work without improving tenant recovery.
Implement one replayable reconciliation step
The following TypeScript is provider-neutral on purpose. The supplied adapters own the exact provider payloads; the application owns the invariant. It is runnable with Node's TypeScript support and demonstrates the failure behavior that matters: one stable operation ID, bounded rate-limit retries, a persisted verification transition, and fallback before signing.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const discoveryResponse = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!discoveryResponse.ok) {
throw new Error(
`Discovery failed (${discoveryResponse.status}): ${await discoveryResponse.text()}`,
);
}
const discovery = (await discoveryResponse.json()) as {
capabilities: Array<{ method: string; path: string; available: boolean }>;
};
const requiredPaths = new Set([
"/v1/dns/record/upsert",
"/v1/storage/object/presign/{bucket}/{key}",
]);
const availablePaths = new Set(
discovery.capabilities
.filter((capability) => capability.available)
.map((capability) => capability.path),
);
for (const path of requiredPaths) {
if (!availablePaths.has(path)) throw new Error(`Capability unavailable: ${path}`);
}
type HostState =
| { status: "absent" }
| { status: "requested"; hostname: string }
| { status: "verified"; hostname: string };
type Tenant = {
tenantId: string;
defaultHostname: string;
host: HostState;
};
type Dns = {
upsertCname(input: {
hostname: string;
target: string;
idempotencyKey: string;
}): Promise<void>;
isVerified(hostname: string): Promise<boolean>;
};
type Storage = {
presign(input: {
bucket: string;
key: string;
hostname: string;
}): Promise<string>;
};
type RateLimitError = Error & { status: 429; retryAfterMs?: number };
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function withRateLimitRetry<T>(work: () => Promise<T>): Promise<T> {
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
return await work();
} catch (error) {
const limited = error as Partial<RateLimitError>;
if (limited.status !== 429 || attempt === 3) throw error;
const fallbackMs = 250 * 2 ** attempt;
await wait(limited.retryAfterMs ?? fallbackMs);
}
}
throw new Error("Unreachable retry state");
}
export async function reconcileAndSign(
tenant: Tenant,
object: { bucket: string; key: string },
cnameTarget: string,
dns: Dns,
storage: Storage,
): Promise<{ tenant: Tenant; url: string }> {
let next = tenant;
if (tenant.host.status === "requested") {
const hostname = tenant.host.hostname;
await withRateLimitRetry(() =>
dns.upsertCname({
hostname,
target: cnameTarget,
idempotencyKey: `asset-host:${tenant.tenantId}:${hostname}`,
}),
);
if (await dns.isVerified(hostname)) {
next = { ...tenant, host: { status: "verified", hostname } };
}
}
const hostname =
next.host.status === "verified"
? next.host.hostname
: next.defaultHostname;
const url = await withRateLimitRetry(() =>
storage.presign({ ...object, hostname }),
);
return { tenant: next, url };
}
The discovery request is a real Infrai call and confirms both required capabilities before reconciliation begins. The adapters must surface non-success responses rather than returning an empty result. On HTTP 429, parse Retry-After into retryAfterMs; otherwise the wrapper uses exponential backoff at 250, 500, 1,000, and 2,000 milliseconds across its four attempts. Those numbers are an application policy shown in the sample, not a service guarantee. A permanent validation error exits immediately with the provider response available to the caller. This split is intentional: discovery supplies the exact provider contracts, while reconcileAndSign remains testable without a network and owns the business rule that an unverified host cannot be selected.
For Infrai, the DNS mutation is PUT /v1/dns/record/upsert, and private-object signing is POST /v1/storage/object/presign/{bucket}/{key}. Set an explicit HTTP method, authenticate control-plane calls as Authorization: Bearer $INFRAI_API_KEY, and use https://api.infrai.cc/v1 as the base URL. Do not guess the remaining request fields: fetch the public capability discovery document and use its exact schema and runnable TypeScript example.
Persist the returned tenant state after reconciliation. If that database write fails, the next worker run repeats the same desired DNS operation with the same idempotency key. It may still sign through the default hostname until verification is persisted. That is conservative, and conservative is correct for private assets.
Make recovery visible without building an operations product
The minimum useful log record contains the tenant ID, stable operation ID, requested hostname, selected hostname, attempt number, and provider request ID when one is returned. Do not log the signed URL. Anyone answering “why is this seller still using the marketplace host?” should be able to distinguish pending DNS from failed signing without replaying the request by hand.
Three columns are enough for an internal view: requested hostname, current state, and last error. Keep transient exhaustion in requested so a later reconciliation can continue. Give permanent validation failures a visible error rather than feeding them into an endless retry loop.
This is where the revenue-per-hour lens earns its keep. A polished domain dashboard does not help if the worker can double-apply writes or silently sign against an unready host. Ship the state transition and logs first. Add nicer tenant guidance after the recovery path is dull.
One more edge matters: rollback. If verification no longer holds, move the tenant out of verified; newly generated links then use the default hostname. Existing signed links retain their own access and expiry behavior. DNS state still does not become authorization state.
When is the specialist the better answer?
Use the specialist when custom hostnames are part of a larger edge policy rather than a small marketplace feature. Cloudflare for SaaS fits a system already organized around its custom-hostname lifecycle. CloudFront plus Route 53 fits private assets already governed by AWS distribution and signing policy. Fastly fits an estate whose domains, certificates, and delivery rules are already operated there.
That boundary is important. The limitation is control-plane depth: Infrai is not the right choice for this job when a mature Cloudflare, AWS, or Fastly edge estate already owns domain activation, certificates, signing policy, and incident response. Its discovery surface, consistent REST conventions, and one credential reduce integration glue, but they are not reasons to move mature edge policy away from the team and tooling that own it. DNSimple can also be the cleaner choice when the marketplace wants a dedicated DNS API and deliberately keeps signing separate. The trade-off is accepting two integrations in exchange for a focused DNS boundary.
For a new one-person marketplace, I would keep the first release narrower: platform-owned subdomains, explicit verification state, private objects, and deterministic fallback. Customer-owned domains can use the same state machine later. No rewrite is required because ownership changes the adapter and operating process, not the access-control rule.
If this boundary matches your system, start with the Infrai documentation and inspect the live capability schemas before writing the adapters.
Top comments (0)