Static Hosting for DeployHQ — Implementation Overview

2 comments0 reviews

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:

  1. Data plane — the 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.
  2. Billy metered billing — PR #184 already ships 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.
  3. 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::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.
  • The 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.
  • The initial 2026 plan for 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.
  • 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_site as 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.


  1. 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.
  2. Customer picks a site permalink (e.g. acme-marketing). Default subdomain is acme-marketing.deployhq-sites.com. Reserved words (admin, www, api, etc.) are rejected inline.
  3. 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.
  4. Customer configures the build command and the output directory on the Project (existing DHQ fields), same as any other Server type.
  5. 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).
  6. 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 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.
  7. 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.
  8. 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

PlaneOwned byChanges in v1
Edgedeployhq-sites repoPrune to Worker + wrangler config; deploy once
Controldeployhq repoAll new code here
Billingbilly repoOne 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::VpsWebsite subclass. Servers::ManagedVps just 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::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:

  • 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 / stateSites allowedPer-site cost
Free plan0n/a (upsell)
Trial1Free during the trial window
Any paid planunlimited£/€/$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_permalink constant (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:

  1. At HostedWebsite provisioning, DHQ calls 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.
  2. Every day, Billy's own 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.
  3. End of month, Stripe aggregates events per billing cycle. First 28 days are billed at £500 / 28 ≈ £17.86 each, days 29+ are billed at £0. Invoice shows one line per HostedWebsite.
  4. On suspension or deprovisioning, Billy hits DHQ's existing 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.
  5. 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:

  • MeteredBillable concern extracted from HostedResource, included on HostedWebsite too.
  • Billy::CallbacksController#metered_subscription_state_change gains a second job dispatch.
  • New HostedWebsites::MeteredBillingStateJob (~40 LOC, mirrors the VPS one).
  • Billy seed migration for the static_hosting_standard package (billing_unit: day, resource_type: static_site).

#Out of v1

  • Custom domains. Schema has a custom_domain column reserved. Cloudflare for SaaS integration deferred. Users get a *.deployhq-sites.com subdomain only in v1.
  • Multiple HostedWebsites per Server. MVP validation caps Servers::StaticHosting at exactly one. Server has_many :hosted_websites is already declared at the base class — no schema change to unblock.
  • Managed VPS hosting HostedWebsites. The 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.
  • 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_total observationally 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_count column to Billy's packages table and adjust one call site. No hosted_websites schema change.

#v2+ extension points, in one sentence each

FeatureHow v1 unblocks it
Custom domainscustom_domain column already present; add a CloudflareForSaaS adapter and a DNS-verification flow
N sites per ServerDrop the at_most_one_hosted_website_for_mvp validation
VPS-hosted websitesWire Servers::ManagedVps#deploy_hosted_websites and reuse the same DeploymentService abstraction
ZIP / API uploadNew controller endpoint that takes an uploaded tarball and calls DeploymentService with a synthetic FileOperation set
Preview environmentsNew HostedWebsite per branch — no new model, just naming conventions
Storage quotasstorage_bytes_total already tracked — add enforcement on write + a grace-period job
Inclusion tiersNew 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:

  1. Cloudflare account configured with the deployhq-sites.com zone and *.deployhq-sites.com wildcard DNS (AAAA record proxied through CF).
  2. Two Cloudflare KV namespaces provisioned and bound in the Worker's wrangler.toml:
    • SITE_ROUTING: subdomainidentifier
    • SITE_CONFIG: identifier{ active_deployment_uuid, spa_mode, storage_bucket }
  3. Cloudflare API token minted with scopes: Workers KV Storage: Edit (v1), Zone DNS Edit (reserved for v2 custom domains).
  4. DigitalOcean Spaces buckets created: deployhq-sites-production and deployhq-sites-staging. Access keys minted.
  5. 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.
  6. Worker deployed once to a Cloudflare Workers environment via wrangler deploy from the pruned deployhq-sites repo (staff-triggered; set up GitHub Actions workflow in that repo).
  7. Billy package seed migration run on production Billy: static_hosting_standard.
  8. account.beta_features = true on the first pilot accounts.

RiskImpactMitigation
MeteredBillable concern extraction regresses managed VPSHighExtraction is behavior-preserving; existing HostedResource spec suite runs unchanged as the net
Cloudflare / Spaces credentials leakHighStored in Rails encrypted credentials, not bare ENV; separate staging/prod keys; tight access
Worker ↔ KV schema drift as we evolveHighVersioned KV config shape ({ "v": 1, … }); Worker logs on unknown versions instead of failing
Operational prerequisites not complete before code shipsMedChecklist above; block merge on staff confirmation
DO Spaces cost growthMedRetention capped at 10 deployments/site; dashboard metric from day one
Pricing changes post-shipMedDHQ couples only to package_permalink; price changes = Billy seed migration only
Cache purge flakiness → stale index.htmlLowShort TTL on non-hashed files; best-effort purge with TTL fallback
Subdomain squattingLowReserved words list (reuse HasDomain concern); unique index; model-level validation

  • Unit specs for HostedWebsite, MeteredBillable (shared examples reused against HostedResource too), Servers::StaticHosting, each service class with mocked adapters, and Protocols::StaticHosting.
  • Adapter specs for CloudflareAdapter and DoSpacesAdapter using VCR cassettes against recorded responses; include 429/503 retry behavior.
  • Controller specs for 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.
  • 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::MeteredBillingStateJob and HostedWebsites::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.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 / worktreeBranchBase
deployhq (new worktree)static-hostingvps-hosting-metered-billing
billy (new worktree)static-hosting-billingvps-hosting
deployhq-sitesnew branch off ai-hackathonai-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

  1. 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.
  2. Retention policy. We default to "last 10 deployments per site" for MVP. Acceptable?
  3. Pilot customers. Who's on the first cohort? Informs when we flip beta_features and how we expect support volume.
  4. Ownership of the deployhq-sites CF account and DO Spaces buckets. Staff only, but we should confirm who holds the credentials and who rotates them.
  5. 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