Status: Proposal, pending team review Scope: v1 (MVP) with explicit v2 extension points Author: Thiago Durante (with Claude) Date: 2026-04-14
We're promoting the Servers::StaticHosting fake door to a real DeployHQ feature: customers can add a Static Hosting server to any Project, push to git, and have the built output deployed to a {permalink}.deployhq-sites.com subdomain served globally via Cloudflare Workers + DigitalOcean Spaces. Each hosted site is a billable resource metered daily at a £/€/$5 monthly cap, wired through Billy using the same metered-billing infrastructure we built for Managed VPS.
The heavy lifting in three places is already done:
deployhq-sites POC has a production-quality Cloudflare Worker that serves requests, resolves subdomain → site → active deployment via KV, and fetches from Spaces. We adopt it as-is and prune the rest of that repo to just Worker + wrangler config.RESOURCE_TYPE__STATIC_SITE, daily billing-unit support, tiered pricing with a 28-day cap, and the full MeteredBilling::* service layer. DHQ-side integration is a one-liner webhook dispatcher change plus a package seed migration on the Billy side.What's actually new code on our side: one HostedWebsite model, one Server STI subclass, one protocol handler, a handful of services + jobs + adapters, a controller, a staff admin controller, and UI. Most of it follows an existing template.
MVP excludes: custom domains (v2), multi-site-per-server (v2), manual/ZIP upload (v2), VPS-hosted websites (v2), preview environments (v2). All are schema-compatible future work — no v1 decision blocks them.
Servers::StaticHosting fake door has been live in the protocol picker (app/models/concerns/server_concerns/protocols.rb:32) as a demand-validation signal. This proposal is based on the clicks that fake door has already generated — it's not speculative.deployhq-sites directory has a working POC (Cloudflare Worker, DO Spaces layout, wrangler config, test sites) that proved the hardest architectural question: can we serve multi-tenant static sites from a single Cloudflare account with subdomain routing? Yes. That work is reusable almost verbatim.deployhq-sites was to ship it as a standalone product with a CLI, drag-drop web UI, and a separate billing stack. That plan was not approved. This proposal is a narrower re-scoping: take the proven infrastructure layer, bolt it onto DHQ as a server protocol, bill through our existing Billy integration, and skip the standalone product.static_site as a first-class metered resource type. The cost of wiring a second metered resource type into DHQ is low.We're not building a Netlify or Vercel competitor. We're adding a hosting surface for DeployHQ customers who today have a build pipeline producing a static output directory (Vite, webpack, Astro, Hugo, Jekyll, Next.js export, etc.) and currently have to figure out their own hosting or pay a second vendor for it. For those customers, "push to DeployHQ, get a live URL" is a strictly additive win — zero new tooling, zero new credentials, one new bill line.
acme-marketing). Default subdomain is acme-marketing.deployhq-sites.com. Reserved words (admin, www, api, etc.) are rejected inline.git push. Standard DHQ deployment pipeline: fetches the repo, runs build commands, produces the output directory, runs all Servers on the Project — the new Servers::StaticHosting handler uploads the build output to DO Spaces under a new UUID folder, flips the Cloudflare KV pointer to the new UUID, and the site goes live. Cache purge fires in the background.┌─────────────────────────────────────────────────────────────────────┐
│ EDGE (data plane) │
│ │
│ *.deployhq-sites.com wildcard DNS │
│ └─► Cloudflare Worker (deployhq-sites repo, staff-deployed) │
│ └─► Cloudflare KV: SITE_ROUTING, SITE_CONFIG │
│ └─► DO Spaces: sites/{id}/deployments/{uuid}/… │
│ │
│ Customer bytes served. Zero DHQ involvement at request time. │
└─────────────────────────────────────────────────────────────────────┘
▲
│ (KV writes, Spaces uploads)
│
┌─────────────────────────────────────────────────────────────────────┐
│ CONTROL PLANE (DeployHQ Rails) │
│ │
│ Models: HostedWebsite (new) + MeteredBillable concern │
│ Servers::StaticHosting (new STI subclass) │
│ │
│ Services: StaticHosting::ProvisioningService │
│ StaticHosting::DeploymentService │
│ StaticHosting::DestructionService │
│ StaticHosting::SuspensionService / Unsuspension │
│ StaticHosting::SyncService, PricingService │
│ StaticHosting::CloudflareAdapter │
│ StaticHosting::DoSpacesAdapter │
│ │
│ Jobs: HostedWebsites::ProvisionJob │
│ HostedWebsites::DestroyJob │
│ HostedWebsites::SyncJob │
│ HostedWebsites::PruneDeploymentsJob │
│ HostedWebsites::PurgeCacheJob │
│ HostedWebsites::MeteredBillingStateJob │
│ │
│ Protocol: Protocols::StaticHosting │
│ DeploymentOperations::Transferring:: │
│ StaticHostingDeploy │
│ │
│ Controllers: HostedWebsitesController │
│ Admin::HostedWebsitesController │
└─────────────────────────────────────────────────────────────────────┘
│
│ register_metered_resource! /
│ deregister_metered_resource!
│ (Ruby library call, not HTTP)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ BILLING PLANE (Billy + Stripe) │
│ │
│ DHQ calls Billy as a Ruby library — no HTTP ingestion endpoint. │
│ Billy owns all meter-event emission via its existing │
│ Metering::BatchJob → SendEventJob → Stripe::Billing::MeterEvent │
│ flow. Billing unit = DAY, cap = 28 days/month. │
│ │
│ Billy → DHQ: metered_subscription_state_change webhook, dispatched │
│ to HostedResources::MeteredBillingStateJob AND │
│ HostedWebsites::MeteredBillingStateJob (new). │
└─────────────────────────────────────────────────────────────────────┘
| Plane | Owned by | Changes in v1 |
|---|---|---|
| Edge | deployhq-sites repo | Prune to Worker + wrangler config; deploy once |
| Control | deployhq repo | All new code here |
| Billing | billy repo | One package seed migration; existing services unchanged |
We introduce one new table (hosted_websites) and one new concern (MeteredBillable). We do not touch the existing hosted_resources table — it continues to represent provider-allocated infrastructure resources (VPS machines, volumes, backups), which static hosting isn't.
HostedWebsite (new, billable)hosted_websites
id
account_id NOT NULL
server_id NOT NULL (any Server STI subclass; v1 always Servers::StaticHosting)
identifier NOT NULL UNIQUE (UUID; stable key handed to Billy, never changes)
permalink NOT NULL (stable per-account slug)
subdomain NOT NULL UNIQUE (e.g. "acme-marketing" → acme-marketing.deployhq-sites.com)
custom_domain nullable (column reserved for v2; unused in v1)
status NOT NULL (enum via MeteredBillable concern)
active_deployment_uuid nullable (points at active folder in Spaces)
billy_metered_resource_id nullable (Billy returns this on registration)
storage_bucket NOT NULL (e.g. "deployhq-sites-production")
spa_mode NOT NULL default: false
storage_bytes_total default: 0 (observational, updated by SyncJob)
metadata jsonb NOT NULL default: {}
created_at, updated_at
Indexes: unique on identifier, unique on subdomain, composite unique (account_id, permalink), non-unique on billy_metered_resource_id.
MeteredBillable concern (new)Extracted from the existing HostedResource behaviour without changing semantics. Holds the shared code path between managed VPS and static hosting:
module MeteredBillable
extend ActiveSupport::Concern
STATUSES = %w[provisioning active suspended error destroying disabled].freeze
included do
belongs_to :account
enum status: STATUSES.each_with_index.to_h, _suffix: :status
validates :identifier, presence: true, uniqueness: true
end
def register_metered_resource!(resource_type:, package_permalink:)
return if billy_metered_resource_id.present?
id = account.billy_service.register_metered_resource(
identifier, resource_type, package_permalink
)
update!(billy_metered_resource_id: id)
end
def deregister_metered_resource!
return if billy_metered_resource_id.blank?
account.billy_service.deregister_metered_resource(billy_metered_resource_id)
update!(billy_metered_resource_id: nil)
end
end
Both HostedResource and HostedWebsite include MeteredBillable. The existing HostedResource spec suite doubles as the regression net for the extraction.
Servers::StaticHosting (new STI subclass)class Servers::StaticHosting < Server
# Server has_many :hosted_websites at the base class level
validate :at_most_one_hosted_website_for_mvp
def supports_atomic_deployments? = true
def supports_build_commands? = true
def requires_repository? = true
def supports_ssh? = false
end
Registered under PROTOCOL_TYPES and moved from FAKE_DOOR_PROTOCOLS to BETA_VISIBLE_PROTOCOLS. Protocol handler is Protocols::StaticHosting.
Account
├─ has_many :hosted_resources (VPS, existing; now include MeteredBillable)
└─ has_many :hosted_websites (new; include MeteredBillable)
Project
└─ has_many :servers (existing STI)
├─ Servers::ManagedVps → has_one :hosted_resource [v1]
│ has_many :hosted_websites [v2+, unused in v1]
└─ Servers::StaticHosting → has_many :hosted_websites [v1, capped at 1 for MVP]
HostedWebsite
├─ belongs_to :account
└─ belongs_to :server (STI parent; v1 always a Servers::StaticHosting)
We declare Server has_many :hosted_websites at the base class. This is deliberately more flexible than v1 needs, because:
hosted_websites).Servers::VpsWebsite subclass. Servers::ManagedVps just gains the ability to have hosted websites associated with it.Servers::StaticHosting via a model validation. When we're ready to open it up, we drop the validation — no migration required.Every folder under sites/{identifier}/deployments/ is a complete, self-sufficient snapshot of the site. Always. This is a hard invariant. The Worker will not fall back to an older folder. Rollback flips one KV value and is instantaneous. Cache-busting is automatic because the UUID is in the URL path.
This matters because DHQ's existing deployment pipeline is incremental — FileOperation records describe only changes since the last deploy. To reconcile that with "every folder is a full copy", the protocol handler's deploy algorithm is:
new_uuid = SecureRandom.uuid
new_prefix = sites/{id}/deployments/{new_uuid}/
previous_uuid = hosted_website.active_deployment_uuid # nil on first deploy
previous_prefix = sites/{id}/deployments/{previous_uuid}/
if previous_prefix exists:
existing_keys = DoSpaces.list(previous_prefix)
removed_paths = file_operations.where(action: :remove).pluck(:filename)
carry_forward = existing_keys - removed_paths
for each path in carry_forward:
DoSpaces.copy_object(previous_prefix + path → new_prefix + path)
# ↑ server-side copy, no bytes cross the DHQ worker network
for each FileOperation with action: :upload:
DoSpaces.put_object(new_prefix + op.filename, op.body, mime, cache_control)
# All uploads succeeded → atomically flip the live pointer
Cloudflare.upsert_config(identifier, { active_deployment_uuid: new_uuid, … })
HostedWebsite.update!(active_deployment_uuid: new_uuid)
# Best-effort, non-blocking:
HostedWebsites::PurgeCacheJob.enqueue(hosted_website_id)
HostedWebsites::PruneDeploymentsJob.enqueue(hosted_website_id)
Net effect: if a deploy moves 1 file, exactly 1 file's worth of bytes crosses DHQ's network. Everything else is handled by Spaces' server-side copy. First deploys — where there's nothing to carry forward — naturally become full uploads because every file is in the FileOperations list.
Why this over the alternatives:
Four properties have to hold together: self-sufficient folders, instant rollback, immutable edge caches, trivial garbage collection. LIST + CopyObject + PutObject is the only strategy that preserves all four.
| Plan / state | Sites allowed | Per-site cost |
|---|---|---|
| Free plan | 0 | n/a (upsell) |
| Trial | 1 | Free during the trial window |
| Any paid plan | unlimited | £/€/$5 monthly cap, metered daily |
Pricing is expected to evolve. The team should treat the £/€/$5 number as a starting point, not a commitment. The DHQ-side code couples to Billy only via a
package_permalinkconstant (static_hosting_standard). A currency change, a price change, or an entirely different tier structure is a Billy seed migration — zero DHQ code changes.
We reuse Billy's metered billing infrastructure built in PR #184. The flow:
account.billy_service.register_metered_resource(identifier, 'static_site', 'static_hosting_standard'). Billy creates a MeteredResource, a Stripe Meter, and a tiered Stripe Price capped at 28 units (days) per month. Returns a billy_metered_resource_id that DHQ stores on the row.Metering::BatchJob enqueues one Metering::SendEventJob per active MeteredResource of type static_site. Those jobs call Stripe::Billing::MeterEvent.create against the resource-specific meter with a value of 1 and an idempotent identifier keyed by (resource_uuid, day). DHQ does not do any of this — Billy owns the cron.£500 / 28 ≈ £17.86 each, days 29+ are billed at £0. Invoice shows one line per HostedWebsite.Billy::CallbacksController#metered_subscription_state_change webhook with metered_resource_identifiers. DHQ dispatches to both HostedResources::MeteredBillingStateJob and HostedWebsites::MeteredBillingStateJob; each filters by its own table.deregister_metered_resource! before any other teardown step. Once the meter is stopped, the rest of the cleanup can safely fail and retry without over-billing.The DHQ-side changes for billing in v1 are therefore tiny:
MeteredBillable concern extracted from HostedResource, included on HostedWebsite too.Billy::CallbacksController#metered_subscription_state_change gains a second job dispatch.HostedWebsites::MeteredBillingStateJob (~40 LOC, mirrors the VPS one).static_hosting_standard package (billing_unit: day, resource_type: static_site).custom_domain column reserved. Cloudflare for SaaS integration deferred. Users get a *.deployhq-sites.com subdomain only in v1.Servers::StaticHosting at exactly one. Server has_many :hosted_websites is already declared at the base class — no schema change to unblock.has_many :hosted_websites association on Servers::ManagedVps is already present at the Server base class level. v1 wires no behavior for it; v2 adds a provisioning path that lays down the app-server layer on the VPS.{ active_deployment_uuid, spa_mode, storage_bucket }. Extending it is a Worker + adapter change, not a schema migration.storage_bytes_total observationally via a sync job but does not enforce anything. The column is in place for v2 quota enforcement.static_hosting_included_count column to Billy's packages table and adjust one call site. No hosted_websites schema change.| Feature | How v1 unblocks it |
|---|---|
| Custom domains | custom_domain column already present; add a CloudflareForSaaS adapter and a DNS-verification flow |
| N sites per Server | Drop the at_most_one_hosted_website_for_mvp validation |
| VPS-hosted websites | Wire Servers::ManagedVps#deploy_hosted_websites and reuse the same DeploymentService abstraction |
| ZIP / API upload | New controller endpoint that takes an uploaded tarball and calls DeploymentService with a synthetic FileOperation set |
| Preview environments | New HostedWebsite per branch — no new model, just naming conventions |
| Storage quotas | storage_bytes_total already tracked — add enforcement on write + a grace-period job |
| Inclusion tiers | New packages.static_hosting_included_count column in Billy + DHQ helper |
These are not code — they're one-time setup that has to exist for the code to work:
deployhq-sites.com zone and *.deployhq-sites.com wildcard DNS (AAAA record proxied through CF).wrangler.toml:
SITE_ROUTING: subdomain → identifierSITE_CONFIG: identifier → { active_deployment_uuid, spa_mode, storage_bucket }deployhq-sites-production and deployhq-sites-staging. Access keys minted.cloudflare.api_token, do_spaces.access_key, do_spaces.secret_key, do_spaces.endpoint, and the bucket names.wrangler deploy from the pruned deployhq-sites repo (staff-triggered; set up GitHub Actions workflow in that repo).static_hosting_standard.account.beta_features = true on the first pilot accounts.| Risk | Impact | Mitigation |
|---|---|---|
MeteredBillable concern extraction regresses managed VPS | High | Extraction is behavior-preserving; existing HostedResource spec suite runs unchanged as the net |
| Cloudflare / Spaces credentials leak | High | Stored in Rails encrypted credentials, not bare ENV; separate staging/prod keys; tight access |
| Worker ↔ KV schema drift as we evolve | High | Versioned KV config shape ({ "v": 1, … }); Worker logs on unknown versions instead of failing |
| Operational prerequisites not complete before code ships | Med | Checklist above; block merge on staff confirmation |
| DO Spaces cost growth | Med | Retention capped at 10 deployments/site; dashboard metric from day one |
| Pricing changes post-ship | Med | DHQ couples only to package_permalink; price changes = Billy seed migration only |
Cache purge flakiness → stale index.html | Low | Short TTL on non-hashed files; best-effort purge with TTL fallback |
| Subdomain squatting | Low | Reserved words list (reuse HasDomain concern); unique index; model-level validation |
HostedWebsite, MeteredBillable (shared examples reused against HostedResource too), Servers::StaticHosting, each service class with mocked adapters, and Protocols::StaticHosting.CloudflareAdapter and DoSpacesAdapter using VCR cassettes against recorded responses; include 429/503 retry behavior.HostedWebsitesController and Admin::HostedWebsitesController, covering beta-gate enforcement and rollback/destroy happy/error paths. The existing spec/controllers/servers_controller_spec.rb fake-door tests are rewritten in the same PR to verify the new real-subclass behavior.HostedResources::MeteredBillingStateJob and HostedWebsites::MeteredBillingStateJob.:active, :suspended, :error, :with_billy) so specs can set up realistic rows in one line.docs/claude/static-hosting-smoke-test.md that staff runs against a real CF + Spaces sandbox before flipping the beta flag on pilot accounts.The split across repositories and an approximate PR shape:
| Repo / worktree | Branch | Base |
|---|---|---|
deployhq (new worktree) | static-hosting | vps-hosting-metered-billing |
billy (new worktree) | static-hosting-billing | vps-hosting |
deployhq-sites | new branch off ai-hackathon | ai-hackathon |
Approximate DHQ-side change volume:
HostedWebsite), 1 new migration, 1 new concern (MeteredBillable, extracted)Servers::StaticHosting), 1 protocol handler, 1 transferring operationapp/services/static_hosting/app/jobs/hosted_websites/HostedWebsitesController, Admin::HostedWebsitesController) + viewsBilly-side:
deployhq-sites repo:
wrangler deploybeta_features and how we expect support volume.deployhq-sites CF account and DO Spaces buckets. Staff only, but we should confirm who holds the credentials and who rotates them.app/models/hosted_website.rb
app/models/concerns/metered_billable.rb
app/models/servers/static_hosting.rb
app/models/concerns/account_concerns/static_hosting.rb
app/controllers/hosted_websites_controller.rb
app/controllers/admin/hosted_websites_controller.rb
app/services/static_hosting/provisioning_service.rb
app/services/static_hosting/deployment_service.rb
app/services/static_hosting/destruction_service.rb
app/services/static_hosting/suspension_service.rb
app/services/static_hosting/unsuspension_service.rb
app/services/static_hosting/sync_service.rb
app/services/static_hosting/pricing_service.rb
app/services/static_hosting/cloudflare_adapter.rb
app/services/static_hosting/do_spaces_adapter.rb
app/jobs/hosted_websites/provision_job.rb
app/jobs/hosted_websites/destroy_job.rb
app/jobs/hosted_websites/sync_job.rb
app/jobs/hosted_websites/prune_deployments_job.rb
app/jobs/hosted_websites/purge_cache_job.rb
app/jobs/hosted_websites/metered_billing_state_job.rb
lib/protocols/static_hosting.rb
lib/deployment_operations/transferring/static_hosting_deploy.rb
app/views/hosted_websites/**
app/views/admin/hosted_websites/**
db/migrate/YYYYMMDDHHMMSS_create_hosted_websites.rb
spec/models/hosted_website_spec.rb
spec/models/concerns/metered_billable_spec.rb
spec/services/static_hosting/**
spec/jobs/hosted_websites/**
spec/controllers/hosted_websites_controller_spec.rb
spec/controllers/admin/hosted_websites_controller_spec.rb
spec/factories/hosted_websites.rb
app/models/hosted_resource.rb (include MeteredBillable)
app/models/server.rb (has_many :hosted_websites)
app/models/concerns/server_concerns/protocols.rb (fake door → beta visible)
app/controllers/billy/callbacks_controller.rb (dual-dispatch the state-change webhook)
app/views/servers/** (StaticHosting protocol fields)
spec/controllers/servers_controller_spec.rb (fake door → real protocol specs)
spec/factories/servers.rb (static_hosting_server factory)
config/routes.rb (hosted_websites resources)
db/migrate/YYYYMMDDHHMMSS_seed_static_hosting_package.rb
spec/migrations/seed_static_hosting_package_spec.rb (if convention)
deployhq-sites).github/workflows/deploy.yml (staff-triggered wrangler deploy)
README.md (rewritten as "production worker for static hosting")
deployhq-sites, during prune)docs/planning/** (POC planning docs, no longer canonical)
poc/scripts/** (shell scripts, replaced by DHQ Rails code)
poc/sites/** (POC test sites)
docs/presentation-* (old standalone-product pitch material)
comments (0)