carlos / platform Public

Platform marketing site live; infra refactored into reusable modules

site/platform: first-draft marketing page, shipped as app
platform-site via canary/site, serving at platform.carlosframework.com.
infra/modules: deployment-buckets + region-host extracted with moved
blocks; both stacks plan zero-change. svc-pdf stays tito-local by
design. Kass replication to the flagship bucket verified (restore
round-trip green) — old Kass account safe to stand down.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 63a8a16cd3f9fff24f924c7d112958f17a8fadb3 parent 1c11de8
22 files changed, +964 −280
  • infra/flagship/dns.tf +2 −2
  • infra/flagship/main.tf +39 −38
  • infra/flagship/outputs.tf +4 −4
  • infra/flagship/plan.out binary
  • infra/flagship/region-ie.tf +37 −96
  • infra/modules/README.md +140 −0
  • infra/modules/deployment-buckets/main.tf +61 −0
  • infra/modules/deployment-buckets/outputs.tf +19 −0
  • infra/modules/deployment-buckets/variables.tf +9 −0
  • infra/modules/deployment-buckets/versions.tf +9 −0
  • infra/modules/region-host/main.tf +121 −0
  • infra/modules/region-host/outputs.tf +29 −0
  • infra/modules/region-host/variables.tf +56 −0
  • infra/modules/region-host/versions.tf +9 −0
  • infra/scripts/promote-platform-site.sh +10 −0
  • infra/scripts/promote-titogo-canary.sh +9 −0
  • infra/tito/dns.tf +1 −1
  • infra/tito/main.tf +40 −38
  • infra/tito/outputs.tf +3 −3
  • infra/tito/region-ie.tf +40 −96
  • infra/tito/svc-pdf.tf +2 −2
  • site/platform/index.html +324 −0
diff --git a/infra/flagship/dns.tf b/infra/flagship/dns.tf
index 2baf40db9..9a4f427b9 100644
--- a/infra/flagship/dns.tf
+++ b/infra/flagship/dns.tf
@@ -18,7 +18,7 @@ resource "dnsimple_zone_record" "oncarlos_wildcard" {
zone_name = "oncarlos.com"
name = "*"
type = "A"
- value = aws_eip.host_ie.public_ip
+ value = module.host_ie.eip_public_ip
ttl = 300
}
@@ -26,6 +26,6 @@ resource "dnsimple_zone_record" "oncarlos_apex" {
zone_name = "oncarlos.com"
name = ""
type = "A"
- value = aws_eip.host_ie.public_ip
+ value = module.host_ie.eip_public_ip
ttl = 300
}
diff --git a/infra/flagship/main.tf b/infra/flagship/main.tf
index 47c21424e..5081f33ae 100644
--- a/infra/flagship/main.tf
+++ b/infra/flagship/main.tf
@@ -3,6 +3,10 @@
# DNS; the platform owns apps, releases, instances, and dynamic DNS.
# Nothing account-shaped (tenant hostnames, routes) may ever enter this
# state — `tofu apply` must always be safe.
+#
+# The reusable shapes live in ../modules (self-hosters consume them by
+# git source URL; see infra/modules/README.md). This root is the thin
+# flagship-specific instantiation: names, sizes, DNS, mail.
terraform {
required_version = ">= 1.10"
@@ -34,56 +38,53 @@ locals {
account = "271376211898"
}
-# The deployment bucket: apps, releases, channels, instance records, host
-# config. Routing metadata only — nothing customer-shaped rests here.
-resource "aws_s3_bucket" "deployment" {
- bucket = "carlos-flagship-${local.account}"
- lifecycle { prevent_destroy = true }
+# The deployment bucket + the IE regional data bucket (the residency
+# promise). Versioning, SSE, public-access block, prevent_destroy — all
+# inside the module.
+module "buckets" {
+ source = "../modules/deployment-buckets"
+ deployment_bucket_name = "carlos-flagship-${local.account}"
+ data_bucket_name = "carlos-data-ie-${local.account}"
}
-# The IE regional data bucket: parked databases, WAL streams, leases,
-# blobs. This bucket is the residency promise.
-resource "aws_s3_bucket" "data_ie" {
- bucket = "carlos-data-ie-${local.account}"
- lifecycle { prevent_destroy = true }
+# State surgery from the pre-module layout (refactor 2026-08-01) — pure
+# bookkeeping, no real resource is touched.
+moved {
+ from = aws_s3_bucket.deployment
+ to = module.buckets.aws_s3_bucket.deployment
}
-resource "aws_s3_bucket_versioning" "deployment" {
- bucket = aws_s3_bucket.deployment.id
- versioning_configuration { status = "Enabled" }
+moved {
+ from = aws_s3_bucket.data_ie
+ to = module.buckets.aws_s3_bucket.data
}
-resource "aws_s3_bucket_versioning" "data_ie" {
- bucket = aws_s3_bucket.data_ie.id
- versioning_configuration { status = "Enabled" }
+moved {
+ from = aws_s3_bucket_versioning.deployment
+ to = module.buckets.aws_s3_bucket_versioning.deployment
}
-resource "aws_s3_bucket_public_access_block" "deployment" {
- bucket = aws_s3_bucket.deployment.id
- block_public_acls = true
- ignore_public_acls = true
- block_public_policy = true
- restrict_public_buckets = true
+moved {
+ from = aws_s3_bucket_versioning.data_ie
+ to = module.buckets.aws_s3_bucket_versioning.data
}
-resource "aws_s3_bucket_public_access_block" "data_ie" {
- bucket = aws_s3_bucket.data_ie.id
- block_public_acls = true
- ignore_public_acls = true
- block_public_policy = true
- restrict_public_buckets = true
+moved {
+ from = aws_s3_bucket_public_access_block.deployment
+ to = module.buckets.aws_s3_bucket_public_access_block.deployment
}
-resource "aws_s3_bucket_server_side_encryption_configuration" "deployment" {
- bucket = aws_s3_bucket.deployment.id
- rule {
- apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
- }
+moved {
+ from = aws_s3_bucket_public_access_block.data_ie
+ to = module.buckets.aws_s3_bucket_public_access_block.data
}
-resource "aws_s3_bucket_server_side_encryption_configuration" "data_ie" {
- bucket = aws_s3_bucket.data_ie.id
- rule {
- apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
- }
+moved {
+ from = aws_s3_bucket_server_side_encryption_configuration.deployment
+ to = module.buckets.aws_s3_bucket_server_side_encryption_configuration.deployment
+}
+
+moved {
+ from = aws_s3_bucket_server_side_encryption_configuration.data_ie
+ to = module.buckets.aws_s3_bucket_server_side_encryption_configuration.data
}
diff --git a/infra/flagship/outputs.tf b/infra/flagship/outputs.tf
index 8aca3bdb6..c58058ade 100644
--- a/infra/flagship/outputs.tf
+++ b/infra/flagship/outputs.tf
@@ -1,15 +1,15 @@
output "ie_eip" {
- value = aws_eip.host_ie.public_ip
+ value = module.host_ie.eip_public_ip
}
output "deployment_bucket" {
- value = aws_s3_bucket.deployment.bucket
+ value = module.buckets.deployment_bucket
}
output "data_bucket_ie" {
- value = aws_s3_bucket.data_ie.bucket
+ value = module.buckets.data_bucket
}
output "host_role_arn" {
- value = aws_iam_role.host.arn
+ value = module.host_ie.role_arn
}
diff --git a/infra/flagship/plan.out b/infra/flagship/plan.out
index c9e4c57b7..8d821cc29 100644
Binary files a/infra/flagship/plan.out and b/infra/flagship/plan.out differ
diff --git a/infra/flagship/region-ie.tf b/infra/flagship/region-ie.tf
index 3b8a43d1c..07f6dbd7b 100644
--- a/infra/flagship/region-ie.tf
+++ b/infra/flagship/region-ie.tf
@@ -1,115 +1,56 @@
# The IE region of the flagship: one Graviton box running `carlos agent`,
# behind an Elastic IP. The box is disposable by construction — all
# durable state is in the buckets — so there is no backup story for hosts
-# at all, only for buckets.
+# at all, only for buckets. Residency is enforced in the module: the host
+# role reaches the deployment bucket plus ITS OWN region's data bucket
+# and nothing else.
-data "aws_ami" "debian_arm64" {
- most_recent = true
- owners = ["136693071363"] # Debian
- filter {
- name = "name"
- values = ["debian-13-arm64-*"]
- }
- filter {
- name = "architecture"
- values = ["arm64"]
- }
+module "host_ie" {
+ source = "../modules/region-host"
+ name = "carlos-flagship-ie"
+ sg_description = "carlos flagship IE host"
+ iam_name = "carlos-flagship-host"
+ instance_type = "t4g.small"
+ root_volume_size = 20
+ user_data = file("${path.module}/host-init.sh")
+ deployment_bucket_arn = module.buckets.deployment_bucket_arn
+ data_bucket_arn = module.buckets.data_bucket_arn
+ ssm_parameter_arn = "arn:aws:ssm:eu-west-1:${local.account}:parameter/carlos/*"
}
-resource "aws_security_group" "host_ie" {
- name = "carlos-flagship-ie"
- description = "carlos flagship IE host"
- ingress {
- from_port = 80
- to_port = 80
- protocol = "tcp"
- cidr_blocks = ["0.0.0.0/0"]
- }
- ingress {
- from_port = 443
- to_port = 443
- protocol = "tcp"
- cidr_blocks = ["0.0.0.0/0"]
- }
- # No SSH ingress: admin access is SSM Session Manager only, and the
- # carlos binary arrives from the deployment bucket, not over scp.
- egress {
- from_port = 0
- to_port = 0
- protocol = "-1"
- cidr_blocks = ["0.0.0.0/0"]
- }
+# State surgery from the pre-module layout (refactor 2026-08-01) — pure
+# bookkeeping, no real resource is touched.
+moved {
+ from = aws_security_group.host_ie
+ to = module.host_ie.aws_security_group.host
}
-resource "aws_iam_role" "host" {
- name = "carlos-flagship-host"
- assume_role_policy = jsonencode({
- Version = "2012-10-17"
- Statement = [{
- Effect = "Allow"
- Principal = { Service = "ec2.amazonaws.com" }
- Action = "sts:AssumeRole"
- }]
- })
+moved {
+ from = aws_iam_role.host
+ to = module.host_ie.aws_iam_role.host
}
-# Residency enforced by construction: the host role reaches the deployment
-# bucket plus ITS OWN region's data bucket and nothing else.
-resource "aws_iam_role_policy" "host_buckets" {
- name = "carlos-flagship-host-buckets"
- role = aws_iam_role.host.id
- policy = jsonencode({
- Version = "2012-10-17"
- Statement = [
- {
- Effect = "Allow"
- Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
- Resource = [
- "${aws_s3_bucket.deployment.arn}/*",
- "${aws_s3_bucket.data_ie.arn}/*",
- ]
- },
- {
- Effect = "Allow"
- Action = ["s3:ListBucket", "s3:GetBucketLocation"]
- Resource = [aws_s3_bucket.deployment.arn, aws_s3_bucket.data_ie.arn]
- },
- {
- Effect = "Allow"
- Action = ["ssm:GetParameter"]
- Resource = "arn:aws:ssm:eu-west-1:${local.account}:parameter/carlos/*"
- },
- ]
- })
+moved {
+ from = aws_iam_role_policy.host_buckets
+ to = module.host_ie.aws_iam_role_policy.host_buckets
}
-resource "aws_iam_role_policy_attachment" "host_ssm" {
- role = aws_iam_role.host.name
- policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+moved {
+ from = aws_iam_role_policy_attachment.host_ssm
+ to = module.host_ie.aws_iam_role_policy_attachment.host_ssm
}
-resource "aws_iam_instance_profile" "host" {
- name = "carlos-flagship-host"
- role = aws_iam_role.host.name
+moved {
+ from = aws_iam_instance_profile.host
+ to = module.host_ie.aws_iam_instance_profile.host
}
-resource "aws_instance" "host_ie" {
- ami = data.aws_ami.debian_arm64.id
- instance_type = "t4g.small"
- vpc_security_group_ids = [aws_security_group.host_ie.id]
- iam_instance_profile = aws_iam_instance_profile.host.name
- user_data = file("${path.module}/host-init.sh")
- user_data_replace_on_change = false
- root_block_device {
- volume_size = 20
- volume_type = "gp3"
- encrypted = true
- }
- tags = { Name = "carlos-flagship-ie" }
+moved {
+ from = aws_instance.host_ie
+ to = module.host_ie.aws_instance.host
}
-resource "aws_eip" "host_ie" {
- domain = "vpc"
- instance = aws_instance.host_ie.id
- tags = { Name = "carlos-flagship-ie" }
+moved {
+ from = aws_eip.host_ie
+ to = module.host_ie.aws_eip.host
}
diff --git a/infra/modules/README.md b/infra/modules/README.md
new file mode 100644
index 000000000..245ee36a9
--- /dev/null
+++ b/infra/modules/README.md
@@ -0,0 +1,140 @@
+# 🤖 CARLOS infra modules
+
+> 🤖 Written by an LLM (Claude), per factor X.
+
+Reusable OpenTofu modules for a CARLOS deployment. Our own stacks
+(`infra/flagship/`, `infra/tito/`) consume them by relative path
+(`source = "../modules/region-host"`); a self-hoster — or Tito, from its
+own repo — consumes the exact same modules by git source URL:
+
+```hcl
+source = "git::https://github.com/carlosframework/platform.git//infra/modules/region-host?ref=main"
+```
+
+The division of labour is unchanged by modularisation: **tofu owns boxes,
+buckets, IAM, and static DNS; the platform owns apps, releases,
+instances, and dynamic DNS.** Nothing account-shaped (tenant hostnames,
+routes) may ever enter tofu state — `tofu apply` must always be safe.
+Providers, backends, and DNS records stay in your root module: they are
+deployment-specific, the modules are not.
+
+## deployment-buckets
+
+The durable pair: the **deployment bucket** (apps, releases, channels,
+instance records, host config — routing metadata only, nothing
+customer-shaped) and one **regional data bucket** (parked databases, WAL
+streams, leases, blobs — the residency promise). Both get versioning,
+AES256 SSE, a full public-access block, and `prevent_destroy`. Boxes are
+disposable; these buckets are not.
+
+| Variable | Description |
+|---|---|
+| `deployment_bucket_name` | Name of the deployment bucket. |
+| `data_bucket_name` | Name of the regional data bucket. |
+
+Outputs: `deployment_bucket`, `deployment_bucket_arn`, `data_bucket`,
+`data_bucket_arn`.
+
+Adding a second region later means a second `deployment-buckets`-shaped
+data bucket; for now each deployment is one region, so the module is a
+pair.
+
+## region-host
+
+One region of a deployment: a Debian arm64 (Graviton) box running
+`carlos agent`, behind an Elastic IP. Provides the AMI lookup, a security
+group admitting 80/443 only (**no SSH** — admin access is SSM Session
+Manager, and the carlos binary arrives from the deployment bucket, not
+over scp), an IAM role scoped to exactly the deployment bucket + this
+region's data bucket + the deployment's SSM parameter prefix, the
+instance profile with `AmazonSSMManagedInstanceCore` attached, the
+instance (encrypted gp3 root), and the EIP.
+
+Residency is enforced by construction: a host in another region gets a
+different module instance whose role does not include this region's data
+bucket, so a wrong-region wake fails loudly at the IAM layer.
+
+| Variable | Description |
+|---|---|
+| `name` | Security group name + `Name` tag (e.g. `carlos-flagship-ie`). |
+| `sg_description` | SG description (changing it forces SG replacement — keep it stable). |
+| `iam_name` | IAM role + instance profile name; inline policy is `<iam_name>-buckets`. |
+| `instance_type` | arm64 instance type, e.g. `t4g.small`. |
+| `root_volume_size` | Root gp3 volume size, GiB. |
+| `user_data` | First-boot init script *content* (pass `file("${path.module}/host-init.sh")`). Changes never replace the instance. |
+| `deployment_bucket_arn` | ARN of the deployment bucket. |
+| `data_bucket_arn` | ARN of THIS region's data bucket. |
+| `ssm_parameter_arn` | SSM parameter ARN pattern the host may read, e.g. `arn:aws:ssm:<region>:<account>:parameter/carlos/*`. |
+| `ami_name_pattern` | AMI name filter (default `debian-13-arm64-*`). |
+| `ami_owner` | AMI owner account (default `136693071363`, Debian). |
+
+Outputs: `eip_public_ip` (point your DNS here), `instance_id`,
+`security_group_id` (for sibling services that admit fleet traffic
+only), `role_arn`, `role_name`, `ami_id`.
+
+Not a module: Tito's `svc/pdf` box stays in `infra/tito/svc-pdf.tf` — it
+is deliberately deployment-local (a stateless Gotenberg whose SG admits
+only the fleet, with *no* bucket or parameter grants; statelessness is
+imposed, not promised). Generalise it only when a second deployment
+wants one.
+
+## Self-hosting from your own repo
+
+A minimal root module — backend + providers + the two module blocks + a
+DNS record. State backend, AWS profile, names, and DNS are yours; the
+shapes are these modules.
+
+```hcl
+terraform {
+ required_version = ">= 1.10"
+ backend "s3" {
+ bucket = "my-tfstate-<account-id>"
+ key = "carlos/terraform.tfstate"
+ region = "eu-west-1"
+ use_lockfile = true
+ }
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ }
+}
+
+provider "aws" {
+ region = "eu-west-1"
+}
+
+locals { account = "123456789012" }
+
+module "buckets" {
+ source = "git::https://github.com/carlosframework/platform.git//infra/modules/deployment-buckets?ref=main"
+ deployment_bucket_name = "carlos-myco-platform-${local.account}"
+ data_bucket_name = "carlos-myco-ie-${local.account}"
+}
+
+module "host_ie" {
+ source = "git::https://github.com/carlosframework/platform.git//infra/modules/region-host?ref=main"
+ name = "carlos-myco-ie"
+ sg_description = "carlos myco IE host"
+ iam_name = "carlos-myco-host-ie"
+ instance_type = "t4g.small"
+ root_volume_size = 20
+ user_data = file("${path.module}/host-init.sh")
+ deployment_bucket_arn = module.buckets.deployment_bucket_arn
+ data_bucket_arn = module.buckets.data_bucket_arn
+ ssm_parameter_arn = "arn:aws:ssm:eu-west-1:${local.account}:parameter/carlos/*"
+}
+
+resource "aws_route53_record" "ie" {
+ zone_id = data.aws_route53_zone.mine.zone_id
+ name = "ie.carlos.example.com"
+ type = "A"
+ ttl = 300
+ records = [module.host_ie.eip_public_ip]
+}
+```
+
+Copy a `host-init.sh` from `infra/flagship/` or `infra/tito/` as your
+starting point, then `tofu init && tofu plan`. Pin `?ref=` to a tag or
+commit rather than `main` once you're live.
diff --git a/infra/modules/deployment-buckets/main.tf b/infra/modules/deployment-buckets/main.tf
new file mode 100644
index 000000000..b6fcbb7b9
--- /dev/null
+++ b/infra/modules/deployment-buckets/main.tf
@@ -0,0 +1,61 @@
+# deployment-buckets: the durable heart of a CARLOS deployment — the
+# deployment bucket (apps, releases, channels, instance records, host
+# config; routing metadata only, nothing customer-shaped) paired with one
+# regional data bucket (parked databases, WAL streams, leases, blobs —
+# the residency promise). Boxes are disposable; these buckets are not,
+# hence prevent_destroy, versioning, SSE, and a full public-access block
+# on both.
+
+# The deployment bucket: apps, releases, channels, instance records, host
+# config. Routing metadata only — nothing customer-shaped rests here.
+resource "aws_s3_bucket" "deployment" {
+ bucket = var.deployment_bucket_name
+ lifecycle { prevent_destroy = true }
+}
+
+# The regional data bucket: parked databases, WAL streams, leases, blobs.
+# This bucket is the residency promise.
+resource "aws_s3_bucket" "data" {
+ bucket = var.data_bucket_name
+ lifecycle { prevent_destroy = true }
+}
+
+resource "aws_s3_bucket_versioning" "deployment" {
+ bucket = aws_s3_bucket.deployment.id
+ versioning_configuration { status = "Enabled" }
+}
+
+resource "aws_s3_bucket_versioning" "data" {
+ bucket = aws_s3_bucket.data.id
+ versioning_configuration { status = "Enabled" }
+}
+
+resource "aws_s3_bucket_public_access_block" "deployment" {
+ bucket = aws_s3_bucket.deployment.id
+ block_public_acls = true
+ ignore_public_acls = true
+ block_public_policy = true
+ restrict_public_buckets = true
+}
+
+resource "aws_s3_bucket_public_access_block" "data" {
+ bucket = aws_s3_bucket.data.id
+ block_public_acls = true
+ ignore_public_acls = true
+ block_public_policy = true
+ restrict_public_buckets = true
+}
+
+resource "aws_s3_bucket_server_side_encryption_configuration" "deployment" {
+ bucket = aws_s3_bucket.deployment.id
+ rule {
+ apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
+ }
+}
+
+resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
+ bucket = aws_s3_bucket.data.id
+ rule {
+ apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
+ }
+}
diff --git a/infra/modules/deployment-buckets/outputs.tf b/infra/modules/deployment-buckets/outputs.tf
new file mode 100644
index 000000000..4542387eb
--- /dev/null
+++ b/infra/modules/deployment-buckets/outputs.tf
@@ -0,0 +1,19 @@
+output "deployment_bucket" {
+ description = "Name of the deployment bucket."
+ value = aws_s3_bucket.deployment.bucket
+}
+
+output "deployment_bucket_arn" {
+ description = "ARN of the deployment bucket."
+ value = aws_s3_bucket.deployment.arn
+}
+
+output "data_bucket" {
+ description = "Name of the regional data bucket."
+ value = aws_s3_bucket.data.bucket
+}
+
+output "data_bucket_arn" {
+ description = "ARN of the regional data bucket."
+ value = aws_s3_bucket.data.arn
+}
diff --git a/infra/modules/deployment-buckets/variables.tf b/infra/modules/deployment-buckets/variables.tf
new file mode 100644
index 000000000..eba25373b
--- /dev/null
+++ b/infra/modules/deployment-buckets/variables.tf
@@ -0,0 +1,9 @@
+variable "deployment_bucket_name" {
+ description = "Name of the deployment bucket (apps, releases, channels, instance records, host config)."
+ type = string
+}
+
+variable "data_bucket_name" {
+ description = "Name of the regional data bucket (parked databases, WAL streams, leases, blobs)."
+ type = string
+}
diff --git a/infra/modules/deployment-buckets/versions.tf b/infra/modules/deployment-buckets/versions.tf
new file mode 100644
index 000000000..c028066c2
--- /dev/null
+++ b/infra/modules/deployment-buckets/versions.tf
@@ -0,0 +1,9 @@
+terraform {
+ required_version = ">= 1.10"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.0"
+ }
+ }
+}
diff --git a/infra/modules/region-host/main.tf b/infra/modules/region-host/main.tf
new file mode 100644
index 000000000..a56f32447
--- /dev/null
+++ b/infra/modules/region-host/main.tf
@@ -0,0 +1,121 @@
+# region-host: one region of a CARLOS deployment — a single Graviton box
+# running `carlos agent`, behind an Elastic IP. The box is disposable by
+# construction: all durable state is in the buckets, so there is no backup
+# story for hosts at all, only for buckets.
+#
+# Residency is enforced by construction: the host role reaches the
+# deployment bucket plus ITS OWN region's data bucket and nothing else. A
+# host in another region gets a different module instance whose role does
+# not include this region's bucket — a wrong-region wake fails loudly at
+# the IAM layer.
+
+data "aws_ami" "debian_arm64" {
+ most_recent = true
+ owners = [var.ami_owner]
+ filter {
+ name = "name"
+ values = [var.ami_name_pattern]
+ }
+ filter {
+ name = "architecture"
+ values = ["arm64"]
+ }
+}
+
+resource "aws_security_group" "host" {
+ name = var.name
+ description = var.sg_description
+ ingress {
+ from_port = 80
+ to_port = 80
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+ ingress {
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+ # No SSH ingress: admin access is SSM Session Manager only, and the
+ # carlos binary arrives from the deployment bucket, not over scp.
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+}
+
+resource "aws_iam_role" "host" {
+ name = var.iam_name
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = { Service = "ec2.amazonaws.com" }
+ Action = "sts:AssumeRole"
+ }]
+ })
+}
+
+# Scoped to exactly the deployment bucket, this region's data bucket, and
+# the deployment's SSM parameter prefix.
+resource "aws_iam_role_policy" "host_buckets" {
+ name = "${var.iam_name}-buckets"
+ role = aws_iam_role.host.id
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
+ Resource = [
+ "${var.deployment_bucket_arn}/*",
+ "${var.data_bucket_arn}/*",
+ ]
+ },
+ {
+ Effect = "Allow"
+ Action = ["s3:ListBucket", "s3:GetBucketLocation"]
+ Resource = [var.deployment_bucket_arn, var.data_bucket_arn]
+ },
+ {
+ Effect = "Allow"
+ Action = ["ssm:GetParameter"]
+ Resource = var.ssm_parameter_arn
+ },
+ ]
+ })
+}
+
+resource "aws_iam_role_policy_attachment" "host_ssm" {
+ role = aws_iam_role.host.name
+ policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+}
+
+resource "aws_iam_instance_profile" "host" {
+ name = var.iam_name
+ role = aws_iam_role.host.name
+}
+
+resource "aws_instance" "host" {
+ ami = data.aws_ami.debian_arm64.id
+ instance_type = var.instance_type
+ vpc_security_group_ids = [aws_security_group.host.id]
+ iam_instance_profile = aws_iam_instance_profile.host.name
+ user_data = var.user_data
+ user_data_replace_on_change = false
+ root_block_device {
+ volume_size = var.root_volume_size
+ volume_type = "gp3"
+ encrypted = true
+ }
+ tags = { Name = var.name }
+}
+
+resource "aws_eip" "host" {
+ domain = "vpc"
+ instance = aws_instance.host.id
+ tags = { Name = var.name }
+}
diff --git a/infra/modules/region-host/outputs.tf b/infra/modules/region-host/outputs.tf
new file mode 100644
index 000000000..0468a3a3e
--- /dev/null
+++ b/infra/modules/region-host/outputs.tf
@@ -0,0 +1,29 @@
+output "eip_public_ip" {
+ description = "Public IP of the host's Elastic IP — the address DNS records point at."
+ value = aws_eip.host.public_ip
+}
+
+output "instance_id" {
+ description = "EC2 instance id of the host."
+ value = aws_instance.host.id
+}
+
+output "security_group_id" {
+ description = "Security group id of the host — useful for sibling services that admit traffic from the fleet only."
+ value = aws_security_group.host.id
+}
+
+output "role_arn" {
+ description = "ARN of the host IAM role."
+ value = aws_iam_role.host.arn
+}
+
+output "role_name" {
+ description = "Name of the host IAM role."
+ value = aws_iam_role.host.name
+}
+
+output "ami_id" {
+ description = "Resolved AMI id (most recent matching image) — reusable by sibling boxes in the same root."
+ value = data.aws_ami.debian_arm64.id
+}
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf
new file mode 100644
index 000000000..830fccfa8
--- /dev/null
+++ b/infra/modules/region-host/variables.tf
@@ -0,0 +1,56 @@
+variable "name" {
+ description = "Host name — used for the security group name and the Name tag on the instance and EIP (e.g. \"carlos-flagship-ie\")."
+ type = string
+}
+
+variable "sg_description" {
+ description = "Security group description. Changing it forces SG replacement — keep it stable."
+ type = string
+}
+
+variable "iam_name" {
+ description = "Name for the IAM role and instance profile (e.g. \"carlos-flagship-host\"). The inline bucket policy is named \"<iam_name>-buckets\"."
+ type = string
+}
+
+variable "instance_type" {
+ description = "EC2 instance type (arm64 Graviton, e.g. \"t4g.small\")."
+ type = string
+}
+
+variable "root_volume_size" {
+ description = "Root gp3 volume size in GiB."
+ type = number
+}
+
+variable "user_data" {
+ description = "First-boot init script content (e.g. file(\"$${path.module}/host-init.sh\")). Changes never replace the instance — hosts are re-initialised via bootstrap, not recreation."
+ type = string
+}
+
+variable "deployment_bucket_arn" {
+ description = "ARN of the deployment bucket this host may reach."
+ type = string
+}
+
+variable "data_bucket_arn" {
+ description = "ARN of THIS region's data bucket — the only data bucket the host role may reach."
+ type = string
+}
+
+variable "ssm_parameter_arn" {
+ description = "SSM parameter ARN pattern the host may read (e.g. \"arn:aws:ssm:eu-west-1:<account>:parameter/carlos/*\")."
+ type = string
+}
+
+variable "ami_name_pattern" {
+ description = "AMI name filter for the host image."
+ type = string
+ default = "debian-13-arm64-*"
+}
+
+variable "ami_owner" {
+ description = "AMI owner account id."
+ type = string
+ default = "136693071363" # Debian
+}
diff --git a/infra/modules/region-host/versions.tf b/infra/modules/region-host/versions.tf
new file mode 100644
index 000000000..c028066c2
--- /dev/null
+++ b/infra/modules/region-host/versions.tf
@@ -0,0 +1,9 @@
+terraform {
+ required_version = ">= 1.10"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.0"
+ }
+ }
+}
diff --git a/infra/scripts/promote-platform-site.sh b/infra/scripts/promote-platform-site.sh
new file mode 100755
index 000000000..b7ff38366
--- /dev/null
+++ b/infra/scripts/promote-platform-site.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+# 🤖 Promote the platform marketing site draft to canary/site (zero bake
+# hold — it's a draft for Paul's review, served at the real hostname
+# because review happens on something deployed, never localhost).
+set -euo pipefail
+carlos=${CARLOS_BIN:-./carlos}
+AWS_PROFILE=keymail AWS_REGION=eu-west-1 \
+CARLOS_DEPLOYMENT_BUCKET=carlos-flagship-271376211898 \
+CARLOS_RELEASE_KEY=$(aws --profile keymail ssm get-parameter --name /carlos/release-key --with-decryption --query Parameter.Value --output text) \
+ "$carlos" promote -app platform-site site1 canary/site
diff --git a/infra/scripts/promote-titogo-canary.sh b/infra/scripts/promote-titogo-canary.sh
new file mode 100755
index 000000000..a7534ba40
--- /dev/null
+++ b/infra/scripts/promote-titogo-canary.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+# 🤖 Promote titogo ff61d3b-r2 (target-keyed re-ship) to its adoption
+# canary channel on the Tito deployment. Key from SSM, never printed.
+set -euo pipefail
+carlos=${CARLOS_BIN:-./carlos}
+AWS_PROFILE=titogo AWS_REGION=eu-west-1 \
+CARLOS_DEPLOYMENT_BUCKET=carlos-tito-platform-665106592142 \
+CARLOS_RELEASE_KEY=$(aws --profile titogo ssm get-parameter --name /carlos/release-key --with-decryption --query Parameter.Value --output text) \
+ "$carlos" promote -app titogo ff61d3b-r2 canary/platform-adoption
diff --git a/infra/tito/dns.tf b/infra/tito/dns.tf
index c842e92db..998aeeced 100644
--- a/infra/tito/dns.tf
+++ b/infra/tito/dns.tf
@@ -14,5 +14,5 @@ resource "aws_route53_record" "ie_alias" {
name = "ie.go.tito.io"
type = "A"
ttl = 300
- records = [aws_eip.host_ie.public_ip]
+ records = [module.host_ie.eip_public_ip]
}
diff --git a/infra/tito/main.tf b/infra/tito/main.tf
index db38e9d74..ab1d62a3a 100644
--- a/infra/tito/main.tf
+++ b/infra/tito/main.tf
@@ -2,6 +2,11 @@
# own; platform spec §12, Tito infra spec). Tofu owns boxes, buckets, IAM,
# and static DNS; the platform owns apps, releases, instances, and dynamic
# DNS. Nothing account-shaped may ever enter this state.
+#
+# The reusable shapes live in ../modules; from Tito's own repo the same
+# modules are consumable by git source URL (see infra/modules/README.md).
+# This root is the thin Tito-specific instantiation: names, sizes, DNS,
+# svc/pdf.
terraform {
required_version = ">= 1.10"
@@ -29,56 +34,53 @@ locals {
account = "665106592142"
}
-# The deployment bucket: apps, releases, channels, instance records, host
-# config. Routing metadata only — nothing customer-shaped rests here.
-resource "aws_s3_bucket" "deployment" {
- bucket = "carlos-tito-platform-${local.account}"
- lifecycle { prevent_destroy = true }
+# The deployment bucket + the IE regional data bucket (the residency
+# promise, Tito infra spec §4). Versioning, SSE, public-access block,
+# prevent_destroy — all inside the module.
+module "buckets" {
+ source = "../modules/deployment-buckets"
+ deployment_bucket_name = "carlos-tito-platform-${local.account}"
+ data_bucket_name = "carlos-tito-ie-${local.account}"
}
-# The IE regional data bucket: parked databases, WAL streams, leases,
-# blobs. This bucket is the residency promise (Tito infra spec §4).
-resource "aws_s3_bucket" "data_ie" {
- bucket = "carlos-tito-ie-${local.account}"
- lifecycle { prevent_destroy = true }
+# State surgery from the pre-module layout (refactor 2026-08-01) — pure
+# bookkeeping, no real resource is touched.
+moved {
+ from = aws_s3_bucket.deployment
+ to = module.buckets.aws_s3_bucket.deployment
}
-resource "aws_s3_bucket_versioning" "deployment" {
- bucket = aws_s3_bucket.deployment.id
- versioning_configuration { status = "Enabled" }
+moved {
+ from = aws_s3_bucket.data_ie
+ to = module.buckets.aws_s3_bucket.data
}
-resource "aws_s3_bucket_versioning" "data_ie" {
- bucket = aws_s3_bucket.data_ie.id
- versioning_configuration { status = "Enabled" }
+moved {
+ from = aws_s3_bucket_versioning.deployment
+ to = module.buckets.aws_s3_bucket_versioning.deployment
}
-resource "aws_s3_bucket_public_access_block" "deployment" {
- bucket = aws_s3_bucket.deployment.id
- block_public_acls = true
- ignore_public_acls = true
- block_public_policy = true
- restrict_public_buckets = true
+moved {
+ from = aws_s3_bucket_versioning.data_ie
+ to = module.buckets.aws_s3_bucket_versioning.data
}
-resource "aws_s3_bucket_public_access_block" "data_ie" {
- bucket = aws_s3_bucket.data_ie.id
- block_public_acls = true
- ignore_public_acls = true
- block_public_policy = true
- restrict_public_buckets = true
+moved {
+ from = aws_s3_bucket_public_access_block.deployment
+ to = module.buckets.aws_s3_bucket_public_access_block.deployment
}
-resource "aws_s3_bucket_server_side_encryption_configuration" "deployment" {
- bucket = aws_s3_bucket.deployment.id
- rule {
- apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
- }
+moved {
+ from = aws_s3_bucket_public_access_block.data_ie
+ to = module.buckets.aws_s3_bucket_public_access_block.data
}
-resource "aws_s3_bucket_server_side_encryption_configuration" "data_ie" {
- bucket = aws_s3_bucket.data_ie.id
- rule {
- apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
- }
+moved {
+ from = aws_s3_bucket_server_side_encryption_configuration.deployment
+ to = module.buckets.aws_s3_bucket_server_side_encryption_configuration.deployment
+}
+
+moved {
+ from = aws_s3_bucket_server_side_encryption_configuration.data_ie
+ to = module.buckets.aws_s3_bucket_server_side_encryption_configuration.data
}
diff --git a/infra/tito/outputs.tf b/infra/tito/outputs.tf
index ab83c78fb..f73fc30fd 100644
--- a/infra/tito/outputs.tf
+++ b/infra/tito/outputs.tf
@@ -1,13 +1,13 @@
output "ie_eip" {
- value = aws_eip.host_ie.public_ip
+ value = module.host_ie.eip_public_ip
}
output "deployment_bucket" {
- value = aws_s3_bucket.deployment.bucket
+ value = module.buckets.deployment_bucket
}
output "data_bucket_ie" {
- value = aws_s3_bucket.data_ie.bucket
+ value = module.buckets.data_bucket
}
output "svc_pdf_private_ip" {
diff --git a/infra/tito/region-ie.tf b/infra/tito/region-ie.tf
index cee70c7cd..33b48aaab 100644
--- a/infra/tito/region-ie.tf
+++ b/infra/tito/region-ie.tf
@@ -2,115 +2,59 @@
# spec §2.2). One Graviton box running `carlos agent`. t4g.medium — the
# Hetzner fleet's whole workload fits with headroom (its redline was 3.8GB
# with ~20 instances at ~190MB).
+#
+# Residency is enforced in the module: this host's role reaches the
+# deployment bucket plus the IE data bucket and nothing else. A future
+# Sydney host's module instance will not include the IE bucket — the
+# wrong-region wake the spec requires to fail loudly fails at the IAM
+# layer.
-data "aws_ami" "debian_arm64" {
- most_recent = true
- owners = ["136693071363"] # Debian
- filter {
- name = "name"
- values = ["debian-13-arm64-*"]
- }
- filter {
- name = "architecture"
- values = ["arm64"]
- }
+module "host_ie" {
+ source = "../modules/region-host"
+ name = "carlos-tito-ie"
+ sg_description = "carlos tito IE host"
+ iam_name = "carlos-tito-host-ie"
+ instance_type = "t4g.medium"
+ root_volume_size = 40
+ user_data = file("${path.module}/host-init.sh")
+ deployment_bucket_arn = module.buckets.deployment_bucket_arn
+ data_bucket_arn = module.buckets.data_bucket_arn
+ ssm_parameter_arn = "arn:aws:ssm:eu-west-1:${local.account}:parameter/carlos/*"
}
-resource "aws_security_group" "host_ie" {
- name = "carlos-tito-ie"
- description = "carlos tito IE host"
- ingress {
- from_port = 80
- to_port = 80
- protocol = "tcp"
- cidr_blocks = ["0.0.0.0/0"]
- }
- ingress {
- from_port = 443
- to_port = 443
- protocol = "tcp"
- cidr_blocks = ["0.0.0.0/0"]
- }
- # No SSH ingress: admin access is SSM Session Manager only.
- egress {
- from_port = 0
- to_port = 0
- protocol = "-1"
- cidr_blocks = ["0.0.0.0/0"]
- }
+# State surgery from the pre-module layout (refactor 2026-08-01) — pure
+# bookkeeping, no real resource is touched.
+moved {
+ from = aws_security_group.host_ie
+ to = module.host_ie.aws_security_group.host
}
-resource "aws_iam_role" "host_ie" {
- name = "carlos-tito-host-ie"
- assume_role_policy = jsonencode({
- Version = "2012-10-17"
- Statement = [{
- Effect = "Allow"
- Principal = { Service = "ec2.amazonaws.com" }
- Action = "sts:AssumeRole"
- }]
- })
+moved {
+ from = aws_iam_role.host_ie
+ to = module.host_ie.aws_iam_role.host
}
-# Residency enforced by construction: this role reaches the deployment
-# bucket plus the IE data bucket and nothing else. A future Sydney host's
-# role will not include the IE bucket — the wrong-region wake the spec
-# requires to fail loudly fails at the IAM layer.
-resource "aws_iam_role_policy" "host_ie_buckets" {
- name = "carlos-tito-host-ie-buckets"
- role = aws_iam_role.host_ie.id
- policy = jsonencode({
- Version = "2012-10-17"
- Statement = [
- {
- Effect = "Allow"
- Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
- Resource = [
- "${aws_s3_bucket.deployment.arn}/*",
- "${aws_s3_bucket.data_ie.arn}/*",
- ]
- },
- {
- Effect = "Allow"
- Action = ["s3:ListBucket", "s3:GetBucketLocation"]
- Resource = [aws_s3_bucket.deployment.arn, aws_s3_bucket.data_ie.arn]
- },
- {
- Effect = "Allow"
- Action = ["ssm:GetParameter"]
- Resource = "arn:aws:ssm:eu-west-1:${local.account}:parameter/carlos/*"
- },
- ]
- })
+moved {
+ from = aws_iam_role_policy.host_ie_buckets
+ to = module.host_ie.aws_iam_role_policy.host_buckets
}
-resource "aws_iam_role_policy_attachment" "host_ie_ssm" {
- role = aws_iam_role.host_ie.name
- policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+moved {
+ from = aws_iam_role_policy_attachment.host_ie_ssm
+ to = module.host_ie.aws_iam_role_policy_attachment.host_ssm
}
-resource "aws_iam_instance_profile" "host_ie" {
- name = "carlos-tito-host-ie"
- role = aws_iam_role.host_ie.name
+moved {
+ from = aws_iam_instance_profile.host_ie
+ to = module.host_ie.aws_iam_instance_profile.host
}
-resource "aws_instance" "host_ie" {
- ami = data.aws_ami.debian_arm64.id
- instance_type = "t4g.medium"
- vpc_security_group_ids = [aws_security_group.host_ie.id]
- iam_instance_profile = aws_iam_instance_profile.host_ie.name
- user_data = file("${path.module}/host-init.sh")
- user_data_replace_on_change = false
- root_block_device {
- volume_size = 40
- volume_type = "gp3"
- encrypted = true
- }
- tags = { Name = "carlos-tito-ie" }
+moved {
+ from = aws_instance.host_ie
+ to = module.host_ie.aws_instance.host
}
-resource "aws_eip" "host_ie" {
- domain = "vpc"
- instance = aws_instance.host_ie.id
- tags = { Name = "carlos-tito-ie" }
+moved {
+ from = aws_eip.host_ie
+ to = module.host_ie.aws_eip.host
}
diff --git a/infra/tito/svc-pdf.tf b/infra/tito/svc-pdf.tf
index a93f44cd1..1eb92c6b7 100644
--- a/infra/tito/svc-pdf.tf
+++ b/infra/tito/svc-pdf.tf
@@ -10,7 +10,7 @@ resource "aws_security_group" "svc_pdf" {
from_port = 443
to_port = 443
protocol = "tcp"
- security_groups = [aws_security_group.host_ie.id]
+ security_groups = [module.host_ie.security_group_id]
}
egress {
from_port = 0
@@ -45,7 +45,7 @@ resource "aws_iam_instance_profile" "svc_pdf" {
}
resource "aws_instance" "svc_pdf" {
- ami = data.aws_ami.debian_arm64.id
+ ami = module.host_ie.ami_id
instance_type = "t4g.small"
vpc_security_group_ids = [aws_security_group.svc_pdf.id]
iam_instance_profile = aws_iam_instance_profile.svc_pdf.name
diff --git a/site/platform/index.html b/site/platform/index.html
new file mode 100644
index 000000000..d1e72d2eb
--- /dev/null
+++ b/site/platform/index.html
@@ -0,0 +1,324 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>The CARLOS Platform</title>
+<meta name="description" content="🤖 The place where CARLOS apps run: ship an immutable release, get a canary URL in seconds, promote the same artifact to stable. Apps sleep to a bucket when idle and wake on demand. The whole platform is one binary, a bucket, and DNS — open, and trivially self-hostable.">
+<meta property="og:title" content="The CARLOS Platform">
+<meta property="og:description" content="A black-box deployment platform for CARLOS apps. One binary, a bucket, and DNS — Heroku's ease, keeping CARLOS's promises.">
+<meta property="og:type" content="website">
+<meta property="og:url" content="https://platform.carlosframework.com/">
+<style>
+ :root {
+ --bg: #ffffff;
+ --ink: #1d2029;
+ --muted: #64707f;
+ --accent: #0f5e85;
+ --accent-soft: rgba(15, 94, 133, 0.09);
+ --rule: #e7e9f0;
+ --max: 42rem;
+ --wide: 62rem;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #14151c;
+ --ink: #eef0f6;
+ --muted: #93a4ba;
+ --accent: #5db8dd;
+ --accent-soft: rgba(93, 184, 221, 0.12);
+ --rule: #2a2e38;
+ }
+ }
+ :root[data-theme="dark"] {
+ --bg: #14151c;
+ --ink: #eef0f6;
+ --muted: #93a4ba;
+ --accent: #5db8dd;
+ --accent-soft: rgba(93, 184, 221, 0.12);
+ --rule: #2a2e38;
+ }
+ :root[data-theme="light"] {
+ --bg: #ffffff;
+ --ink: #1d2029;
+ --muted: #64707f;
+ --accent: #0f5e85;
+ --accent-soft: rgba(15, 94, 133, 0.09);
+ --rule: #e7e9f0;
+ }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ background: var(--bg);
+ color: var(--ink);
+ font: 400 1.0625rem/1.65 Charter, Georgia, "Times New Roman", serif;
+ -webkit-font-smoothing: antialiased;
+ padding: 0 1.25rem;
+ }
+ a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
+ strong { font-weight: 700; }
+ code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.88em; background: var(--accent-soft);
+ padding: 0.1em 0.35em; border-radius: 0.25rem;
+ }
+
+ /* hero */
+ header {
+ max-width: var(--wide); margin: 0 auto;
+ padding: 5rem 0 3rem; text-align: center;
+ }
+ h1 {
+ font-size: 2.9rem; line-height: 1.1; letter-spacing: 0.01em;
+ margin: 0 0 0.85rem; font-weight: 700;
+ }
+ .tagline { font-size: 1.2rem; color: var(--muted); font-style: italic; }
+ .lede {
+ font-size: 1.2rem; max-width: 38rem;
+ margin: 1.5rem auto 0;
+ }
+ .cta {
+ margin: 2rem 0 0; display: flex; gap: 0.75rem;
+ justify-content: center; flex-wrap: wrap;
+ }
+ .btn {
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ font-size: 0.95rem; font-weight: 600; text-decoration: none;
+ padding: 0.65rem 1.25rem; border-radius: 0.5rem;
+ }
+ .btn.primary { background: var(--accent); color: var(--bg); }
+ .btn.ghost { color: var(--accent); border: 1px solid var(--rule); }
+ .btn.ghost:hover, .btn.primary:hover { filter: brightness(1.1); }
+ .robot-note {
+ margin: 2.25rem auto 0; padding: 0.6rem 0.9rem; max-width: 36rem;
+ background: var(--accent-soft); border-radius: 0.5rem;
+ font-size: 0.9rem; color: var(--muted);
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ }
+ .robot-note a { color: var(--accent); }
+
+ /* stat strip */
+ .stats {
+ max-width: var(--wide); margin: 0 auto;
+ border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
+ text-align: center;
+ }
+ .stats div { padding: 1.4rem 0.5rem; }
+ .stats .n { display: block; font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1.1; }
+ .stats .l {
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ font-size: 0.85rem; color: var(--muted);
+ }
+
+ /* sections */
+ main { max-width: var(--wide); margin: 0 auto; padding: 0 0 3rem; }
+ section { padding: 3rem 0; }
+ section + section { border-top: 1px solid var(--rule); }
+ section > * { max-width: var(--max); margin-left: auto; margin-right: auto; }
+ section > .cards { max-width: var(--wide); }
+ h2 {
+ font-size: 1.6rem; line-height: 1.25; font-weight: 700;
+ letter-spacing: -0.01em; margin-bottom: 0.4rem; text-align: center;
+ }
+ h2 a { color: inherit; text-decoration: none; }
+ h2 a:hover { color: var(--accent); }
+ .epigraph {
+ font-size: 1.15rem; font-style: italic; color: var(--muted);
+ margin-bottom: 1.4rem; text-align: center;
+ }
+ p { margin: 0 0 1.1rem; }
+ section > p:last-child { margin-bottom: 0; }
+
+ /* cards */
+ .cards {
+ display: grid; gap: 1rem; margin-top: 1.75rem;
+ grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
+ }
+ .card {
+ border: 1px solid var(--rule); border-radius: 0.75rem;
+ padding: 1.25rem 1.35rem;
+ }
+ .card.feature { border-color: var(--accent); background: var(--accent-soft); }
+ .card h3 { font-size: 1.1rem; font-weight: 700; margin: 0 0 0.35rem; }
+ .card .kicker {
+ display: block; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ font-size: 0.78rem; font-weight: 600; letter-spacing: 0.06em;
+ text-transform: uppercase; color: var(--accent); margin-bottom: 0.3rem;
+ }
+ .card p { color: var(--muted); font-size: 0.97rem; margin: 0; }
+
+ .keyline {
+ margin: 1.5rem auto; padding: 1rem 1.1rem;
+ border-left: 3px solid var(--accent);
+ background: var(--accent-soft);
+ border-radius: 0 0.4rem 0.4rem 0;
+ }
+ .keyline p:last-child { margin-bottom: 0; }
+
+ ul.plain { list-style: none; margin: 1.1rem auto; }
+ ul.plain li {
+ padding: 0.45rem 0 0.45rem 1.25rem; position: relative;
+ }
+ ul.plain li::before {
+ content: "—"; position: absolute; left: 0; color: var(--accent);
+ }
+
+ footer {
+ max-width: var(--wide); margin: 0 auto;
+ border-top: 1px solid var(--rule);
+ padding: 2rem 0 4rem; text-align: center;
+ color: var(--muted); font-size: 0.95rem;
+ }
+ footer p { margin-bottom: 0.6rem; max-width: var(--max); margin-left: auto; margin-right: auto; }
+</style>
+</head>
+<body>
+
+<header>
+ <h1>The CARLOS Platform</h1>
+ <p class="tagline">Where CARLOS apps run.</p>
+ <p class="lede">A black-box deployment platform for <a href="https://carlosframework.com">CARLOS</a> apps: ship a release, and the platform places it on instances, routes to it, sleeps it when idle, and wakes it on demand. Heroku's ease, keeping CARLOS's promises — and the platform itself is one binary, a bucket, and DNS.</p>
+ <div class="cta">
+ <a class="btn primary" href="#ship">What shipping looks like</a>
+ <a class="btn ghost" href="#status">How young is it?</a>
+ </div>
+ <p class="robot-note">🤖 This page was written by an LLM, on the ideas, instruction, and editing of humans. AI-written text here is always marked, visibly, like this — that's <a href="https://11factor.org/#x">factor X</a>.</p>
+</header>
+
+<div class="stats">
+ <div><span class="n">1</span><span class="l">binary, playing every role</span></div>
+ <div><span class="n">0</span><span class="l">control-plane servers</span></div>
+ <div><span class="n">0</span><span class="l">rebuilds from canary to stable</span></div>
+ <div><span class="n">72h</span><span class="l">bake before stable adopts</span></div>
+</div>
+
+<main>
+
+<section id="what">
+ <p>The framework defines the shape of an app; the platform is the substrate it runs on — the way Heroku stood beside the twelve factors. An author ships <strong>releases</strong>: immutable, content-addressed artifacts. The platform places them on <strong>instances</strong>, routes to them through a mesh, hibernates them when idle, and wakes them on the request that needs them. Apps from many authors run side by side on the same fleet, sharing the same blind infrastructure.</p>
+
+ <div class="keyline">
+ <p>The whole self-hosting story is one sentence: <strong>a deployment is a bucket, N hosts running one binary, and DNS.</strong> Keeping it one sentence is a design constraint on everything else.</p>
+ </div>
+
+ <p>There is no control-plane server to stand up, and none to be locked out of. All durable platform state — apps, releases, channels, instance records, leases — lives in an object-store bucket; hosts converge by watching it, and a burned-down box is replaced by pointing a fresh one at the bucket. The platform makes about itself the promise its instances already make: <strong>the box is disposable, the bucket is not.</strong> It is entirely open and trivially self-hostable, and two of its first three deployments <em>are</em> self-hosted — so anything the flagship needs that a self-hoster can't have is a design error, caught immediately.</p>
+</section>
+
+<section id="ship">
+ <h2><a href="#ship">Ship, canary, promote</a></h2>
+ <p class="epigraph">The dev pipeline is the prod pipeline, exercised on every push.</p>
+
+ <p><code>carlos ship</code> uploads a release under its content hash. A <strong>channel</strong> is a per-app pointer to a release — <code>stable</code>, <code>beta</code>, and ephemeral <code>canary/*</code> channels — and advancing one is a single pointer write. Instances subscribe to a channel and roll onto new releases at their next natural boundary: wake from hibernation, or a drain-and-restart sweep. Rollback is the same operation backwards.</p>
+
+ <div class="cards">
+ <div class="card">
+ <h3>Every branch gets a canary URL</h3>
+ <p><code>carlos canary</code> from a worktree builds, ships, creates a channel, spawns one instance bound to sandbox glue, and returns its URL — in seconds, not minutes. A dev environment is not a separate system; it is a channel with one subscriber.</p>
+ </div>
+ <div class="card">
+ <h3>Promotion never rebuilds</h3>
+ <p><code>carlos promote</code> points a wider channel at the same hash: canary → beta → stable. Every environment running release <code>abc123</code> runs byte-identical code. What you tested is what ships, because it is the same bytes.</p>
+ </div>
+ <div class="card">
+ <h3>Rollback is a pointer move</h3>
+ <p>A bad release hits gradually — instances adopt on wake — and pointing the channel back at the previous hash stops the bleed. Additive-only migrations mean old data stays readable under new code and new data survives the trip back.</p>
+ </div>
+ <div class="card">
+ <h3>Static sites ride the same channels</h3>
+ <p>A static release is a content-addressed file tree the edge serves directly — no process, nothing to hibernate. Same canary URLs per branch, same promotion, same rollback. This page ships that way.</p>
+ </div>
+ </div>
+</section>
+
+<section id="run">
+ <h2><a href="#run">Sleeping is the default state</a></h2>
+ <p class="epigraph">Most software is used a few hours a week. The platform bills like it.</p>
+
+ <p>An idle instance hibernates, and its database <strong>parks</strong> to object storage: a parked app is a few objects in a bucket — no process, no memory, no host doing anything on its behalf. The next request wakes it, on whichever eligible host answers first. Availability is continuous even when execution isn't.</p>
+
+ <ul class="plain">
+ <li><strong>Per-instance databases, continuously replicated.</strong> Every instance owns its own database, and it streams to the bucket from the first byte. Losing a host costs a restart, not a history: its leases expire and the instances wake elsewhere from their last parked state.</li>
+ <li><strong>Scheduling is deliberately dumb.</strong> No bin-packing optimizer — hibernation makes placement mistakes cheap. A badly-placed instance parks and wakes somewhere better next time.</li>
+ <li><strong>Scale is a declaration, not a rewrite.</strong> Parked → embedded SQLite → replicated → an attached server database (one per instance, inside the app's trust domain, never multi-tenant) → a dedicated pool. An app moves along that curve by changing its manifest, not its architecture.</li>
+ </ul>
+</section>
+
+<section id="place">
+ <h2><a href="#place">Placement you can point at</a></h2>
+ <p class="epigraph">Residency is enforced where the instance wakes, not promised in a policy PDF.</p>
+
+ <div class="cards">
+ <div class="card">
+ <span class="kicker">Residency</span>
+ <h3>Choose where data lives</h3>
+ <p>An app — or a single instance, because one customer may demand EU while another doesn't care — declares <code>residency: eu</code>. A host outside the region will not claim the lease, so there is no code path where the instance runs elsewhere "just this once during a failover": no eligible host up means the wake fails loudly. Parked data, streams, and blobs rest in region-scoped buckets. Residency covers rest as well as execution.</p>
+ </div>
+ <div class="card">
+ <span class="kicker">Dedicated pools</span>
+ <h3>Your own hardware, same platform</h3>
+ <p>Apps that hold real personal or payment data — not everything is end-to-end encrypted — can reserve hosts: their instances, parked data, and wake path only ever touch that pool, optionally with its own bucket, while still riding the shared edge, channels, CLI, and services.</p>
+ </div>
+ </div>
+</section>
+
+<section id="services">
+ <h2><a href="#services">Shared services: blind or seeing, never both</a></h2>
+ <p class="epigraph">Nothing that sees may store.</p>
+
+ <p>Every shared service carries a declared class, visible in the manifest of every app that consumes it. <strong>Blind services</strong> may hold data, but only data they cannot read: the object store holds parked databases and blobs but no plaintext of E2EE apps; the vault holds sealed blobs whose keys live elsewhere; the push relay holds device tokens and timing but never notification content; the OAuth relay passes tokens through sealed to the requesting instance, never retaining them. What makes them blind is falsifiable the CARLOS way — publish their state and see what leaks.</p>
+
+ <p><strong>Seeing services</strong> process plaintext — a URL unfurler, a PDF renderer, outbound mail — and are therefore forbidden to hold it. Not by promise: the platform grants a seeing service no database binding, no bucket write access, no persistent volume. Statelessness is imposed by construction. One honest caveat the mail service carries with it: the underlying email provider retains delivery metadata — bounces, complaints, suppressions — scoped per tenant.</p>
+</section>
+
+<section id="supply">
+ <h2><a href="#supply">Supply-chain honesty</a></h2>
+ <p class="epigraph">The scary step in any pipeline is the pointer that says "this is stable now."</p>
+
+ <ul class="plain">
+ <li><strong>Channel pointers are signed.</strong> A host agent rolls instances onto a release only when the pointer advancing the channel carries a valid signature — a bucket write alone is not a deployment.</li>
+ <li><strong>Releases bake before stable adopts them.</strong> A release must sit on its channel for 72 hours before stable instances roll onto it — time for a poisoned artifact to be noticed while its blast radius is still the people who opted in.</li>
+ <li><strong>Rollback never bakes.</strong> Pointing back at a hash that already served traffic takes effect immediately. The waiting period protects against new code, and going backwards is never new code.</li>
+ </ul>
+
+ <p>Every release records its build attestation — commit and builder — under its hash, and every platform change ships through the platform itself: worktree, canary channel, a miniature canary deployment, a headless-browser gate that fails on any console error. The release machinery is tested by being the only way anything ships, including itself.</p>
+</section>
+
+<section id="where">
+ <h2><a href="#where">Three deployments, one platform</a></h2>
+ <p class="epigraph">The Open promise is proven from day one, because the first two production users are self-hosters.</p>
+
+ <div class="cards">
+ <div class="card">
+ <h3>Tito</h3>
+ <p>Ticketing for conferences and events, running its own self-hosted deployment on its own account. Self-hosted use-case number one, and the platform's first proving ground.</p>
+ </div>
+ <div class="card">
+ <h3>Eleven</h3>
+ <p>The encrypted messenger, on its own deployment — and the code quarry: the platform's edge, host agent, channels, and mergeable stores were extracted from Eleven's proven machinery.</p>
+ </div>
+ <div class="card feature">
+ <h3>The flagship cloud</h3>
+ <p>The shared multi-tenant deployment, operated by the CARLOS authors, hosting Keymail, Woodstar, Slopbox, and Kass — the first four customers. Same open platform, differing from the self-hosted two in variables, never in shape.</p>
+ </div>
+ </div>
+
+ <p style="margin-top:1.75rem">Apps live at <code>&lt;sqid&gt;.oncarlos.com</code> — an address that never encodes region or host, so relocation and placement stay invisible to it. Custom domains ride on top as aliases.</p>
+</section>
+
+<section id="status">
+ <h2><a href="#status">Where it actually stands</a></h2>
+ <p class="epigraph">Fewer claims, all of them checkable — including this one.</p>
+
+ <p>The platform is young. It was extracted from Eleven and titogo — systems that kept rebuilding the same deployment shape until it was obviously one thing — and its first production tenant, Kass, migrated onto it on 2026-08-01. Everything above describes the design being built and proven, not a mature product with years of scar tissue. If that honesty reads oddly on a homepage — good. It is the same trade the framework makes: the platform holds itself to the promises it hosts, and a promise you can't check yet is stated as exactly that.</p>
+</section>
+
+</main>
+
+<footer>
+ <p>🤖 Written by an LLM (Claude), on the ideas, instruction, and editing of humans. AI-written text here is always marked and always disclosed — see <a href="https://11factor.org/#x">factor X</a>.</p>
+ <p>The platform is the substrate for apps built on <a href="https://carlosframework.com">the CARLOS framework</a>, keeping the promises set out in <a href="https://11factor.org">The Eleven Factors</a> — the way Heroku stood beside the twelve.</p>
+ <p><a href="https://github.com/carlosframework/platform">Source for the platform — and this site — on GitHub</a>. Open, of course.</p>
+</footer>
+
+</body>
+</html>