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 Enterprise-only. On non-Enterprise plans all custom hostnames land on the zone's single fallback origin
Multi-region Recommended Traffic hairpins through one proxy host
External dependency None beyond a Caddy reverse proxy A Cloudflare zone + API token

If multi-region matters, use Caddy.

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). Scope the token to the zone with SSL and Certificates: Edit and Custom Hostnames: Edit, then click Test Connection.
    • 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).

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

  • 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 SSL active. Check the token scope and that the proxied CNAME-target record exists. Failures are recorded on the row (Retry after fixing).
  • 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