> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/web-hosting/provisioning.md).

# Hosting catalog, control plane and provisioning

Public routes: /web-hosting/ (indexed coming-soon plan comparison), /let-beta/ (noindex, nofollow offer page), /let-beta/reserve/ (authenticated CSRF-protected POST).

## 6.1 Catalog and LET Beta <a href="#id-61-catalog-and-let-beta" id="id-61-catalog-and-let-beta"></a>

Public routes: `/web-hosting/` (indexed coming-soon plan comparison), `/let-beta/` (`noindex, nofollow` offer page), `/let-beta/reserve/` (authenticated CSRF-protected POST).

`WebHostingPlan` owns price and resource intent: monthly and hourly prices; free-trial days; storage, domain, MySQL, PostgreSQL and mailbox counts; per-mailbox storage quota; CloudLinux CPU, PMEM, I/O, IOPS, entry-process and process limits; and Apache / PHP Selector / PostgreSQL / hosted-email feature gates.

The LET Beta row is fixed at: 30 free days after successful provisioning; `$0.00160273` per hour after the trial (which rounds to `$1.17` for 730 hours); 128 MiB PMEM, VMEM 0; one domain, one MySQL database, one PostgreSQL database, one 256 MiB mailbox, 1 GiB total storage. CloudLinux recommends at least 512 MB PMEM, so **the 128 MB tier is explicitly a beta/staging exception**; other seeded plans carry 512 MB, 1 GiB and 2 GiB.

The offer is a **30-day free trial with no credit card and no account credit required to deploy**. After the trial, hosting continues at `$1.17`/month billed hourly from account credit. If the account has no credit when the 30 days end, the service is cancelled automatically and no post-trial hour is billed. A saved card without credit does not keep the trial alive. While a LET Beta trial is active and the account has neither a saved card nor credit, the Hosting Control Center shows a warning at the top with that deletion date.

**Once any customer is assigned to a plan, repricing or changing its limits requires a new plan row and a migration path** — editing the live row would alter the desired state of existing services with no customer-level event. Same rule as [4.2](/platform/billing/model-and-catalog.md#id-42-catalog-products-plans-and-the-pricing-rules).

Reservation requires the LET campaign enabled, the plan active, and the billing account active. It does **not** require account credit, a saved card, or `hourly_billing_unlocked_at`. It is idempotent per billing account and plan. Reservation and activation honour tenant and payer billing/KYC holds. Customer reservation **rechecks current tenant access after locking the account and organization**, so joining or leaving cannot let a stale request create an inaccessible service. An authenticated customer without a billing profile gets one created on the reserve POST.

**Customer inventory.** Reserved, active and suspended hosting services appear beside virtual servers in the shared `/client/services/` **Instances** inventory, scoped to the signed-in customer's billing account, with provisioning state in the same polling contract as a deploying VM, omitting cancelled or ended accounts just as VM inventory omits destroyed servers. The legacy `/client/web-hosting/` list redirects there.

Each hosting instance opens a categorized control center modelled on familiar hosting panels — **Files, Domains, Email, Databases, Software, Account** — with a searchable tool grid whose tools each have **their own URL, focused heading, account summary and a shared tool rail**, rather than one oversized page. SSL/TLS, email accounts, forwarders and webmail are focused workspaces rather than duplicate renderings of a combined form.

**Adding a domain or subdomain configures only that hostname and document root; it does not create a certificate resource or submit an ACME action.** The owned SSL/TLS workspace is the customer entry point for explicitly issuing, retrying or manually renewing a managed certificate after the domain is active; once a customer has opted in, the bounded renewal worker may continue renewing it when due.

New domain/subdomain intents derive the readable document root `domains/<fully-qualified-hostname>/public_html`. **Pre-upgrade immutable jobs with `domains/<resource-id>/public_html` remain valid for replay, and an already bound node mapping keeps that legacy path** rather than moving tenant content during a retry or recreate.

A service with no saved PHP Selector settings starts with the complete reviewed extension allowlist selected; an existing saved selection, **including an intentionally empty one**, remains authoritative.

## 6.2 Node control plane <a href="#id-62-node-control-plane" id="id-62-node-control-plane"></a>

A global registry at `/console/web-hosting/` with list, detail, register, edit and test-connection screens. Production node communication uses **HTTPS and a unique API key generated on the hosting server** (`layerone-web-server generate-key` prints the key and its SHA-256 verifier; the verifier goes in the node's root-owned configuration and the key is pasted into the panel's write-only form). The portal stores its recoverable copy **encrypted by `FIELD_ENCRYPTION_KEY`**, because it must send the key on outbound requests. mTLS and certificate pinning are intentionally not part of this model for tenant operations; there is no application CIDR allowlist — deployment firewall and egress policy are infrastructure responsibilities.

The form accepts the bare `l1web_...` credential, the command's complete two-line output, or its `-json` output; complete output is parsed and **its verifier is checked** before only the raw bearer credential is encrypted. A SHA-256 verifier pasted by itself is rejected. Every probe sends `Authorization: Bearer <key>`; the agent hashes it and does a constant-time comparison. Missing, malformed and incorrect credentials get a bounded 401 with no secret echo, and access logs do not record request headers. Rotation starts on the node (generate, update the verifier, paste the replacement); the audit records that the key changed without recording plaintext or ciphertext.

**Node identity is required out of band.** Registration requires the exact expected `node_id`; health and capability responses must agree with it, **first-contact trust is not learned from the node**, identity drift is refused, and the database enforces uniqueness for non-blank remote IDs.

The bounded v1 client reads `GET /v1/health` and `GET /v1/capabilities` with independent timeouts, a 1 MiB response cap, redirects and ambient proxies disabled, strict response typing, and **no remote response bodies in errors or logs**. Successful responses use the envelope `{api_version: "v1", data: {...}, meta: {request_id: "..."}}` and return the same request ID in `X-Request-ID`. Only normalized node status, agent version, request ID and typed capability records are stored; last errors are locally generated bounded messages.

**Capability records are a typed API inventory. They are not node placement or customer-control authority** — every enabled online node is eligible, and each requested mutation still fails closed unless its owned resource state and exact post-mutation readback match. Every operator-triggered connection test is audited, and results are discarded if any connection-relevant configuration changes while the probe is in flight.

`webhosting.sync_nodes` (`WEBHOSTING_NODE_SYNC_INTERVAL_SECONDS`=30, `WEBHOSTING_NODE_SYNC_LIMIT`=10) reuses that bounded probe so enabled nodes refresh automatically and waiting reservations no longer depend on an operator repeating **Test connection**. A successful probe updates stored identity and inventory, then transactionally attaches the node to older `Awaiting node` reservations and makes their shared jobs due.

**A node row's API origin and expected `node_id` become immutable as soon as any hosting service is assigned to it.** Operators replace or drain a node by registering a new row; they never repoint a bound row at a different host. Placement and node editing serialize on the node row, and every provisioning result rechecks the exact endpoint identity used before I/O before accepting local state. API-key rotation remains supported for the same endpoint. If an unbound row's origin or identity changes, the portal clears its health and capability evidence and requires a fresh connection test.

Support is default-denied; the navigation entry is hidden by the Infrastructure guard.

## 6.3 Provisioning and activation <a href="#id-63-provisioning-and-activation" id="id-63-provisioning-and-activation"></a>

A LET reservation creates a `Reserved` `WebHostingService`, assigns an immutable job ID and derived system username/domain, records a bounded audit event, and creates a first-class shared billing `ProvisioningJob` targeted at the service (older reservations missing that link are backfilled when placed or reconciled). **The shared job is the portal authority for attempts, errors, retries and provisioning status; the service remains the customer desired-state record.**

**Reservation deliberately leaves `activated_at`, `trial_ends_at` and `billing_starts_at` empty.** The node owns Unix UID allocation and returns the observed UID after provisioning.

The shared `billing.run_pending_provisioning_jobs` dispatcher claims both VM and web-hosting jobs and routes by type — **web hosting has no separate provisioning dispatcher or Beat schedule.** It creates or reuses a typed `ensure_hosting_account` v2 action, commits the action request ID **before** network I/O, exact-replays that ID after an ambiguous response, and accepts the node-assigned `act-*` identity. A terminal failed attempt gets a fresh request ID. **Activation happens only after exact identity and effective-limit readback** (username, UID, primary domain, plan limits).

Healthy pending/running observations make the same job due again after `WEBHOSTING_PROVISIONING_POLL_INTERVAL_SECONDS` (5), preserving its exact action identity and completed steps **without consuming a failure retry**; `WEBHOSTING_PROVISIONING_POLL_TIMEOUT_SECONDS` (900) bounds observation from the action's original submission so repeated healthy responses cannot extend it indefinitely. Transient node errors and expired deadlines use the same bounded automatic-retry policy as other initial provisioning adapters; exhaustion records a terminal FAILED job rather than an unbounded pending loop.

When an enabled online node **deterministically rejects** the action at admission (a reached-node `POST /v2/actions` HTTP 400/409, or HTTP 404 `not_found` because an older release lacks that endpoint), the portal terminalizes the exact action and shared attempt **once**, shows the installed node API-contract mismatch, and **does not manufacture another action during page access or periodic convergence**. A GET 404 for an already-assigned action remains a distinct missing-action observation. After the node is updated, an explicit retry control creates a new desired generation.

**The free period starts only through `activate_webhosting_service`** after a node has actually provisioned the account: it rechecks that the billing account is not on hold (credit and a card are not required), binds the node, sets `activated_at`, and sets both `trial_ends_at` and `billing_starts_at` to exactly 30 days later. A five-minute Beat task (`webhosting.meter_hourly_usage`) first cancels any free-trial service whose 30 days have ended with no account credit, admitting whole-service removal so an unpaid trial never accrues usage. Remaining active services then get one immutable `WebHostingUsageRecord` and one account-ledger debit for **each completed hour** after the paid boundary; the service row is locked and advances its meter cursor in the same transaction, so retries cannot duplicate an hour. Metering also skips a service that already has an unfinished removal workflow, and re-checks the unfunded-trial rule under the same locks so a concurrent worker cannot post a first hour the cancel pass already decided against.

Each shared-job attempt records the actual bounded v2 HTTP exchange **separately from action identity**: method, path, transport correlation ID, status, typed error code/class, and whether the node was reached. **It never fabricates a 200/202 exchange from local action state and never stores remote bodies.**

**Suspension and recovery** create durable account-scoped management jobs with exact `suspend_hosting_account {id,cause}` / `unsuspend_hosting_account {id}` payloads. `WebHostingService.lifecycle_state` shows pending, confirmed or failed reconciliation **independently** from observed status: the service becomes `Suspended` or returns to `Active` only after an exact node result for that generation, and network errors or partial node failures never become false success. Opposite generations reconcile in order, and the portal never guesses that a pending row was not already accepted by an in-flight request. Suspension also **immediately revokes unrevealed credential material**.

Cancellation before the committed dispatch marker makes no node call. Cancellation after it exact-replays an ambiguous submission to recover the node action, then observes or applies account-ID-bound security suspension — **a failed account action does not prove that no partial account exists.**

Rolling-upgrade branches exist only for work already admitted through v1: a reserved row with a persisted authority generation and request hash continues that exact lineage to its terminal result or fail-closed compensation before a v2 ensure action may bind, and management rows already `SUBMITTED`/`RUNNING` without a node-owned action ID are **GET-only against their original v1 `job_id`** until terminal and adopt that outcome — never resubmitted through v2, which prevents non-idempotent backup or restore work from being repeated during a rolling deployment.

## 6.3.1 Operator-created hosting services <a href="#id-631-operator-created-hosting-services" id="id-631-operator-created-hosting-services"></a>

**Web hosting → Create service** (`webhosting:admin-service-create`) reserves a hosting account for a client who is not going to order it themselves. `reserve_let_beta` cannot serve this: it is welded to the LET beta slug and the `let_beta_hosting_enabled` promotion switch, neither of which is the right gate for an operator placing an agreed plan. `admin_service_creation .create_client_webhosting_service` is the general form of the same reservation — any `is_active` plan, public or not, no promo switch — and then runs the identical sequence: provisioning identity, node placement, shared `ProvisioningJob`.

**It reserves; it does not activate.** The node still has to provision the account, and `activate_webhosting_service` is still the only thing that starts the free period and the billing clock. There is no second provisioning path.

The account lock, shared-billing lock, hold check, domain claim and the one-live-service-per-(account, plan) constraint all behave exactly as on the public path; the per-plan collision is refused in words rather than surfacing as an `IntegrityError`. Sales being paused is deliberately not consulted, for the same reason as [5.14.1](/platform/vps/operator-management.md#id-5141-operator-created-services).

**Free hosting is `WebHostingService.hourly_rate_override`**, a nullable `Decimal(12, 8)` mirroring `VirtualMachine.hourly_rate_override`. `0` is a real value meaning "given away", not an absence. Everything that prices an hour goes through **`services.webhosting_effective_hourly_rate`** — hourly metering, the removal settlement and the activation billability check — so an override can also price a service whose plan is still "coming soon". A free service is **exempt from the day-30 unfunded-trial cancellation**: `_unfunded_trial_is_due` returns False at a zero effective rate, because a service that bills nothing cannot be unfunded, and without that the sweep would delete the whole account for holding no credit against a bill never sent. Hours are still metered and recorded at `$0.00`, so the service keeps a billing history to reprice against.

A free service requires a typed reason, recorded on the audit trail (`webhosting.admin_created`). Absent from `SUPPORT_CONSOLE_ROUTES`; `create_client_webhosting_service` re-checks `actor.is_admin` itself.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/web-hosting/provisioning.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
