Static Hosting for DeployHQ — Implementation Overview
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:
- Data plane — the
deployhq-sitesPOC 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. - Billy metered billing — PR #184 already ships
RESOURCE_TYPE__STATIC_SITE, daily billing-unit support, tiered pricing with a 28-day cap, and the fullMeteredBilling::*service layer. DHQ-side integration is a one-liner webhook dispatcher change plus a package seed migration on the Billy side. - Managed VPS patterns — domain model, provisioning rollback ladder, beta-gating, admin page, destruction flow, suspension via Billy callback. We mirror these patterns rather than reinvent them.
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.
#Product context
- The
Servers::StaticHostingfake 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. - The
deployhq-sitesdirectory 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. - The initial 2026 plan for
deployhq-siteswas 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. - We just finished shipping metered billing for Managed VPS (DHQ PRs #678 and #721; Billy PR #184). That work unlocks per-resource metered billing with tiered pricing and caps, and Billy already has
static_siteas a first-class metered resource type. The cost of wiring a second metered resource type into DHQ is low.
#Market position
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.
- Customer opens a Project, clicks Add Server. Static Hosting is visible in the protocol picker under the "Hosting" category. Beta accounts can create; non-beta accounts see it disabled with a "Beta" badge — same pattern as Managed VPS.
- Customer picks a site permalink (e.g.
acme-marketing). Default subdomain isacme-marketing.deployhq-sites.com. Reserved words (admin,www,api, etc.) are rejected inline. - Customer toggles SPA mode if needed — a single checkbox: "Serve index.html for non-file routes". On by default for React/Vue/SPA-shaped repos.
- Customer configures the build command and the output directory on the Project (existing DHQ fields), same as any other Server type.
- Customer clicks Create. Server + HostedWebsite rows created in DHQ; provisioning job enqueued. Within a second or two the UI shows the site in Active state with the subdomain live (serving a default "no deploys yet" page from the Worker).
- Customer does
git push. Standard DHQ deployment pipeline: fetches the repo, runs build commands, produces the output directory, runs all Servers on the Project — the newServers::StaticHostinghandler 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. - Customer looks at the site's page in DHQ. Sees the active deployment, a list of previous deployments, a "Rollback to this version" button on each, and the current storage usage.
- Customer clicks Rollback. KV pointer flips back to a previous UUID synchronously. Cache purge fires. Site is rolled back in seconds without a new build.
┌─────────────────────────────────────────────────────────────────────┐
│ 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). │
└─────────────────────────────────────────────────────────────────────┘
#Ownership summary
| 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.
#Association graph
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)
#Cardinality note — 0, 1, or N websites per Server
We declare Server has_many :hosted_websites at the base class. This is deliberately more flexible than v1 needs, because:
- It costs nothing at the schema level (one FK column on
hosted_websites). - The v2 "Managed VPS frontend abstraction" — where a single managed VPS hosts a backend plus one or more frontends — falls out for free without a new
Servers::VpsWebsitesubclass.Servers::ManagedVpsjust gains the ability to have hosted websites associated with it. - The v2 "monorepo with multiple static sites from one repo" scenario also falls out for free.
- In v1 we enforce exactly one HostedWebsite per
Servers::StaticHostingvia 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:
- Always full re-upload would be simpler, but a 500 MB site with a 1 KB change would push 500 MB through DHQ every deploy. Kills us at scale.
- Referenced / symlinked folders would be cheaper, but break rollback (deleting an old folder would corrupt a newer one that depended on it) and break Cloudflare's cache-immutability story.
- Manifest file per deployment saves a LIST call at the cost of a write on every deploy and a new "manifest-vs-actual-files drift" failure mode.
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.
#Pricing for v1
| 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.
#How it ticks
We reuse Billy's metered billing infrastructure built in PR #184. The flow:
- At HostedWebsite provisioning, DHQ calls
account.billy_service.register_metered_resource(identifier, 'static_site', 'static_hosting_standard'). Billy creates aMeteredResource, a Stripe Meter, and a tiered Stripe Price capped at 28 units (days) per month. Returns abilly_metered_resource_idthat DHQ stores on the row. - Every day, Billy's own
Metering::BatchJobenqueues oneMetering::SendEventJobper activeMeteredResourceof typestatic_site. Those jobs callStripe::Billing::MeterEvent.createagainst 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. - End of month, Stripe aggregates events per billing cycle. First 28 days are billed at
£500 / 28 ≈ £17.86each, days 29+ are billed at £0. Invoice shows one line per HostedWebsite. - On suspension or deprovisioning, Billy hits DHQ's existing
Billy::CallbacksController#metered_subscription_state_changewebhook withmetered_resource_identifiers. DHQ dispatches to bothHostedResources::MeteredBillingStateJobandHostedWebsites::MeteredBillingStateJob; each filters by its own table. - At destruction, DHQ calls
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:
MeteredBillableconcern extracted fromHostedResource, included onHostedWebsitetoo.Billy::CallbacksController#metered_subscription_state_changegains a second job dispatch.- New
HostedWebsites::MeteredBillingStateJob(~40 LOC, mirrors the VPS one). - Billy seed migration for the
static_hosting_standardpackage (billing_unit: day,resource_type: static_site).
#Out of v1
- Custom domains. Schema has a
custom_domaincolumn reserved. Cloudflare for SaaS integration deferred. Users get a*.deployhq-sites.comsubdomain only in v1. - Multiple HostedWebsites per Server. MVP validation caps
Servers::StaticHostingat exactly one.Server has_many :hosted_websitesis already declared at the base class — no schema change to unblock. - Managed VPS hosting HostedWebsites. The
has_many :hosted_websitesassociation onServers::ManagedVpsis 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. - ZIP / drag-drop upload and public upload API. v1 is git-pipeline only. Schema & protocol handler don't assume repo-ness in a way that blocks these; they just aren't exposed.
- Preview environments / branch-based deploys. Not in v1. The HostedWebsite model doesn't enshrine "one branch = one site", so adding this later is additive.
- Per-site Worker customization. No custom headers, redirects, cache-control overrides, or error pages in v1. The Worker config is a fixed shape:
{ active_deployment_uuid, spa_mode, storage_bucket }. Extending it is a Worker + adapter change, not a schema migration. - Hard storage / bandwidth quotas. v1 tracks
storage_bytes_totalobservationally via a sync job but does not enforce anything. The column is in place for v2 quota enforcement. - Inclusion tiers in packages. Every paid plan in v1 is metered at the same rate with zero included sites. When we want "Solo: 1 included, Pro: 3 included", we add a
static_hosting_included_countcolumn to Billy'spackagestable and adjust one call site. Nohosted_websitesschema change.
#v2+ extension points, in one sentence each
| 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:
- Cloudflare account configured with the
deployhq-sites.comzone and*.deployhq-sites.comwildcard DNS (AAAA record proxied through CF). - Two Cloudflare KV namespaces provisioned and bound in the Worker's
wrangler.toml:SITE_ROUTING:subdomain→identifierSITE_CONFIG:identifier→{ active_deployment_uuid, spa_mode, storage_bucket }
- Cloudflare API token minted with scopes: Workers KV Storage: Edit (v1), Zone DNS Edit (reserved for v2 custom domains).
- DigitalOcean Spaces buckets created:
deployhq-sites-productionanddeployhq-sites-staging. Access keys minted. - Rails encrypted credentials updated per environment with
cloudflare.api_token,do_spaces.access_key,do_spaces.secret_key,do_spaces.endpoint, and the bucket names. - Worker deployed once to a Cloudflare Workers environment via
wrangler deployfrom the pruneddeployhq-sitesrepo (staff-triggered; set up GitHub Actions workflow in that repo). - Billy package seed migration run on production Billy:
static_hosting_standard. account.beta_features = trueon 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 |
- Unit specs for
HostedWebsite,MeteredBillable(shared examples reused againstHostedResourcetoo),Servers::StaticHosting, each service class with mocked adapters, andProtocols::StaticHosting. - Adapter specs for
CloudflareAdapterandDoSpacesAdapterusing VCR cassettes against recorded responses; include 429/503 retry behavior. - Controller specs for
HostedWebsitesControllerandAdmin::HostedWebsitesController, covering beta-gate enforcement and rollback/destroy happy/error paths. The existingspec/controllers/servers_controller_spec.rbfake-door tests are rewritten in the same PR to verify the new real-subclass behavior. - Integration specs walking the full provisioning, deployment, rollback, destruction, suspension, and unsuspension flows end to end with stubbed external APIs.
- Billy webhook dispatcher spec covering the new dual-dispatch to both
HostedResources::MeteredBillingStateJobandHostedWebsites::MeteredBillingStateJob. - Factory traits for every HostedWebsite state (
:active,:suspended,:error,:with_billy) so specs can set up realistic rows in one line. - Manual smoke test documented in
docs/claude/static-hosting-smoke-test.mdthat 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:
- 1 new model (
HostedWebsite), 1 new migration, 1 new concern (MeteredBillable, extracted) - 1 Server STI subclass (
Servers::StaticHosting), 1 protocol handler, 1 transferring operation - 6 services under
app/services/static_hosting/ - 2 adapter classes (Cloudflare, DoSpaces)
- 6 jobs under
app/jobs/hosted_websites/ - 2 controllers (
HostedWebsitesController,Admin::HostedWebsitesController) + views - 1 Billy webhook dispatcher one-liner change
- Fake-door → real-protocol removal + registry updates
- Factory, fixtures, and full spec coverage
Billy-side:
- 1 package seed migration
deployhq-sites repo:
- Prune POC scaffolding
- Keep and productionize the Worker
- Add a GitHub Actions workflow for
wrangler deploy
- Sign-off on the pricing number. £/€/$5 is plausible but should be confirmed by whoever owns pricing. The code is decoupled from the number so this isn't a blocker — but it affects the Billy seed migration that lands on day one.
- Retention policy. We default to "last 10 deployments per site" for MVP. Acceptable?
- Pilot customers. Who's on the first cohort? Informs when we flip
beta_featuresand how we expect support volume. - Ownership of the
deployhq-sitesCF account and DO Spaces buckets. Staff only, but we should confirm who holds the credentials and who rotates them. - Worker ↔ KV schema versioning discipline. I'm proposing versioned KV payloads with backward-compat reads, but this is a process question as much as a code one — do we want a "Worker deploys first, then DHQ uses new shape" policy written down?
#New files
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
#Modified files (DHQ)
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)
#New files (Billy)
db/migrate/YYYYMMDDHHMMSS_seed_static_hosting_package.rb
spec/migrations/seed_static_hosting_package_spec.rb (if convention)
#New files (deployhq-sites)
.github/workflows/deploy.yml (staff-triggered wrangler deploy)
README.md (rewritten as "production worker for static hosting")
#Removed files (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)
2 comments
+2 more