Knowledge Base

Custom Domains — Setup, Backends & Review Queue

The S3 Custom Domains plugin lets clients serve their public bucket over their own subdomain (for example files.customer.com), sold as a configurable-option add-on. Requests are DNS-verified automatically, then require staff approval before they go live. Its page lives in the staff navigation as S3 Custom Domains (under Tools).

Reads only. SigV4 binds host and path, so the S3 API, mc, and presigned share links keep using the platform domain. Custom domains serve anonymous public-bucket reads. This is stated in the client UI and cannot be configured away.

Choose a backend (per server / module row)

Each S3 server (module row) picks the backend under Custom Domain Backend:

Caddy (on-demand TLS) Cloudflare for SaaS
TLS certificates Let's Encrypt, issued on demand, gated by an "ask" endpoint Cloudflare-issued
Per-region origins Native — the Host header is rewritten to each region All custom hostnames land on the zone's one fallback origin; the origin (HAProxy snippet) routes each hostname to its region
Multi-region Native Works; remote-region bytes pass through the origin host
Apex domains (example.com) No (subdomains only) Yes, when the client's DNS provider supports ALIAS records or CNAME flattening
Video and large files Served over the custom domain Redirected to the platform address (CDN terms); keep video-heavy tenants on Caddy
External dependency None beyond a Caddy reverse proxy A Cloudflare zone with Cloudflare for SaaS enabled + API token

One-time setup

  1. Install the plugin (Settings > Company > Plugins). The 5-minute cron registers automatically.
  2. Open the plugin Manage page and set:
    • Map token (auto-generated; used by the Caddy map-sync script — regenerate to rotate).
    • Cloudflare API token / Zone ID / CNAME target (Cloudflare mode only). First enable Cloudflare for SaaS on the zone and set the proxied CNAME-target record as the fallback origin (status active). Scope the token to the zone with SSL and Certificates: Edit and Custom Hostnames: Edit, then click Test Connection. The test fails when the fallback origin is missing or inactive, the top cause of Cloudflare error 1016.
    • Staff notification email (who gets the "domain verified, awaiting review" email).
  3. On each S3 server (module row), set Custom Domain Backend and Public Domain (the region's public endpoint, e.g. fr.sf-objectstorage.com). The public domain is the CNAME target in Caddy mode, the Host the proxy normalizes to, and the origin advertised on the map endpoint. It must be a valid hostname (or left blank to disable) — the field is validated when you save the row.
  4. Deploy the reverse proxy from proxy/SETUP-custom-domains.md (Caddy or Cloudflare mode). When another proxy already owns port 443 on the region host, run Caddy on a dedicated host: set the Public Domain to that host's name and point the Caddyfile upstream at the region endpoint (guide section A6).

A client cannot register a custom domain that equals or is a subdomain of any of these reserved platform hostnames: any server's Public Domain (every module row in the company, not just the one their service lives on), the Cloudflare CNAME target, or your Blesta company hostname.

Create the "Custom Domain" configurable option

The client tab only offers the feature when the service carries a configurable option named custom_domain with a truthy value:

  1. Packages > Package Options > add a group, add an option with Name exactly custom_domain (a checkbox/enabled option or a select whose selected value is not empty/0/false).
  2. Price it as you like (this is the add-on charge).
  3. Attach the option group to the relevant packages.
  4. Existing clients add it via Manage Options on the service; new clients see it in the order form.

Review queue

The plugin page has Pending Review, All, and Setup tabs.

  • Columns: domain, client (clickable), service (clickable), bucket, backend, Verified ✓/✗ with last-checked time, status, requested. Columns are sortable and paginated.
  • Approve / Reject (with an optional note) appear on verified rows. Both are POST-only and CSRF-protected.
  • Retry resets the error counter on a row the cron gave up on (5 failures).
  • The Setup tab is a readiness checklist: each module row's public_domain, Cloudflare credential status, and the two endpoint URLs.

State machine

pending_dns --(CNAME + TXT verified)--> verified --(staff emailed once)
pending_dns --(no verification within 14 days)--> removing
verified --(approve)--> approved --(cron provisions)--> active
verified --(reject)--> rejected            (name freed; client may re-submit it)
active <-> suspended                       (service suspend/unsuspend)
any live --> removing --(cron tears down)--> removed

Verification now proves ownership, not just a CNAME. Since module/plugin
2.4.1/1.0.1, a request must satisfy both a CNAME pointed at the expected
target AND a _sf-challenge.<domain> TXT record matching a token generated at
request time, before it moves from pending_dns to verified. Requests
created on earlier versions have no token and continue to verify on the CNAME
alone (grandfathered — nothing to do on upgrade). A CNAME match without the
TXT record is not treated as an error; the row simply stays pending_dns
until the client adds it.

Abandoned requests expire after 14 days. A pending_dns row that never
completes verification is automatically flagged for removal (freeing the
domain name) 14 days after it was requested, so unclaimed names don't pile up.

The gate is enforcing: the ask endpoint and the map endpoint only ever include approved/active rows, and in Cloudflare mode the custom hostname is not created until approval.

Suspension holds in-flight domains still. While a service is suspended, its domain does not progress — the cron skips verifying/provisioning it and Approve is blocked until the service is active again. Only active domains flip to suspended; a pending_dns/verified/approved domain simply pauses.

Dropping the add-on removes the domain. If the client removes the custom_domain option from an active service (or the service is deleted), the cron detects this on its next run (it reconciles each live domain against the service's current options) and tears the domain down — there is no need to click Remove on the client tab.

Rejected/removed names are reusable. When a domain reaches rejected or removed its reserved name is freed (the audit row is kept, with the original domain shown in the queue and preserved in its note), so the same hostname can be requested again later.

Endpoints

  • check (/plugin/minio_custom_domains/check/?domain=) — Caddy's on-demand-TLS ask URL. Returns 200 only for approved/active domains, else 404.
  • map (/plugin/minio_custom_domains/map/) — text/plain domain bucket origin_host lines for approved+active rows of the company that owns the token. The token is sent as an X-Map-Token request header (preferred; proxy/caddy/map-sync.sh uses this) or, for backward compatibility, the ?token= query parameter. 403 on a bad/missing token.

Troubleshooting

  • Record exists but stays unverified for hours — a resolver that looked the name up before the record existed caches "not found" for the zone's SOA minimum TTL (often hours). Ask the client to create the CNAME before they submit and the TXT right after, or lower the SOA minimum of zones you control.
  • CNAME not verifying — confirm the client created a CNAME (not A/AAAA) at the target shown on their Custom Domain tab, that it is a subdomain (apex domains can't hold a CNAME), and give DNS time to propagate. Also confirm the TXT record (_sf-challenge.<domain>) matches the token shown — both are required on requests created after the ownership-verification update; older pending requests verify on the CNAME alone. The client's "Check now" button forces a re-check (rate-limited to once a minute).
  • Cloudflare DCV stuck — the hostname stays approved until Cloudflare reports both the hostname and its certificate active. Check the token scope and that the proxied CNAME-target record exists. A terminal Cloudflare state (moved, blocked, validation_timed_out, expired) is recorded on the row with Cloudflare's reason; fix the cause, then Retry. A hostname that already exists in the zone is adopted, not duplicated.
  • Domain active but returns 404 / AccessDenied — the bucket was made private. Custom domains serve public reads only; make the bucket public again under Bucket Options, or the client should remove the domain.
  • RustFS — custom-domain provisioning works on RustFS (it is proxy-side); only usage billing remains MinIO-only.
Please rate this article to help us improve our Knowledge Base.

0 0