carlos / platform Public
Rails behind the CARLOS edge: the Classic/Pro proof of concept
Date: 2026-08-11 Status: Approved design, staging slice first Prompted by: James running miren (miren.dev, a container deployment tool) behind the carlos router. Question under test: can the CARLOS tito deployment front the two big Rails apps — Tito Classic and Tito Pro — well enough that migrating them off Kubernetes onto a much simpler all-CARLOS deployment is a real option rather than a slogan.
Context
Tito Classic and Tito Pro run today on Kubernetes, with Postgres, Redis and Memcached dependencies, in the same AWS account and region as the CARLOS tito boxes (665106592142, eu-west-1). Buildkite already builds Docker images for the k8s deployment; those images are amd64, and the PoC runs them as-is on x86 boxes (Paul, 2026-08-11: these boxes are disposable — they'll be turned off after the evaluation, so rebuilding for arm64 buys nothing here). arm64 only matters if Rails-on-CARLOS graduates to a first-class deployment on the Graviton fleet, and that is recorded as roadmap fallout (§7), not PoC work.
CARLOS today has no container runtime and its release format is a single static binary — a Rails app cannot be a CARLOS release. But the edge already fronts processes it didn't build: a route can proxy to an arbitrary TCP host:port (-kind service -addr, internal/registry/registry.go), and the svc-pdf box (platform-infrastructure/tito/svc-pdf.tf) is the live precedent for "a Docker-shaped dependency on its own tofu-provisioned box, trusted via security group". The PoC composes exactly those two precedents and deliberately nothing new in the platform.
Two facts remove whole workstreams: *.platform.tito.io already resolves to the IE box's EIP (tito/dns.tf, wildcard A record), and the edge mints per-host HTTP-01 certs automatically the moment a route exists. DNS and TLS are zero work.
Decisions taken during design
- Runtime on the boxes: bare box — docker + systemd (svc-pdf pattern scaled up). Miren was considered (closest to James's PoC) and rejected as a second control plane pointing away from "simpler all-CARLOS"; Kamal was rejected because it requires SSH (the fleet is deliberately SSM-only) and inserts its own proxy.
- Reuse the k8s images unmodified — the PoC runs exactly what Buildkite builds (amd64); no separate build pipeline, no arm64 rebuilds, no source checkouts on boxes.
- Staging first. Boxes wired to STAGING Postgres/Redis/Memcached now; production boxes are written in tofu in the same PR but applied only after the staging PoC convinces us.
- Full process tree — web + workers + cron, so the boxes are true parallel replicas of the k8s deployment sharing its queues and cache. Workers pulling real staging jobs from the shared Redis is the point, not a hazard.
- Reuse existing staging dependencies rather than on-box replacements; an on-box memcached container is a documented fallback only.
Design
1. Boxes
Two new EC2 instances in platform-infrastructure/tito/, one .tf + cloud-init file each, copying the svc-pdf shape:
rails-classic-stagingandrails-pro-staging- x86 (amd64), because that is what the Buildkite images are —
t3a.largeto start (t4g is Graviton-only, so the burstable x86 class is t3/t3a), sized honestly after the first week; an amd64 Debian AMI data source (the fleet's shared AMI is arm64), withlifecycle { ignore_changes = [ami] }(the floating-AMI replace trap is recorded in svc-pdf.tf) - Own security group each; ingress only the app port from the IE box's SG (
module.host_ie.security_group_id) — same trust boundary as svc-pdf - IAM:
AmazonSSMManagedInstanceCore+ ECR pull on the Classic/Pro repositories +ssm:GetParameteron/tito/rails-poc/<app>-staging/*. Nothing else — no bucket grants. - No SSH. All operation via SSM, like the rest of the fleet.
rails-classic-prod.tf / rails-pro-prod.tf are written in the same PR, count = 0 (or commented apply instructions) until the staging PoC passes.
2. On each box
Cloud-init installs docker + the ECR credential helper, materializes the app env from SSM Parameter Store (/tito/rails-poc/<app>-staging/env, written to /etc/rails-poc/<app>.env, 0600), and writes systemd units — one per process type, each a docker run on the pinned image tag:
rails-web.service— the Rails/puma web process, port published on the box's private IPrails-worker.service— background workers (one unit per worker class if the k8s deployment splits them)rails-cron.service— clock/scheduler process, if the app has one- all
Restart=always, logs to journald
The env file carries DATABASEURL / REDISURL / MEMCACHED / SECRETKEYBASE etc., populated once by hand from the k8s staging secrets (values never in tofu, never in the repo).
3. Images
The Buildkite-built amd64 images are used unmodified, pulled from the same registry the k8s deployment pulls from. The boxes track the same tags k8s staging deploys, so the two deployments stay comparable during the parallel run. No rebuilds of any kind for the PoC.
4. Dependencies
Staging Postgres/Redis/Memcached are reached by adding ingress rules on their security groups admitting the new boxes' SGs. Preflight before apply: confirm the staging RDS/Elasticache share a VPC with the default VPC these boxes land in (same account/region is confirmed; the svc-pdf pattern uses the default VPC). If EKS keeps its data stores in its own VPC, place the PoC boxes in that VPC instead — a variable on the box resources, not a redesign — or peer.
5. Edge routing
On the IE box (via SSM, as user carlos):
carlos add -kind service -addr <classic-private-ip>:<port> classic.platform.tito.io
carlos add -kind service -addr <pro-private-ip>:<port> pro.platform.tito.ioGotchas carried from exploration:
- Confirm which registry the edge reads before adding routes — the tito box carries two (
/data/registry.dbplatform,/data/titogo/registry.dbtitogo's own). - Never set
-hibernateon these routes: hibernation drives the exec/unit backends and their--socket/--db/healthzcontract, which these boxes don't speak. - Repointing an upstream = re-run
carlos add(it upserts); there is nocarlos route -addrsetter. - Addr-upstream routes are "externally managed by design" (
internal/host/socket_advice.go) — no config delivery, no version stamping, no log shipping, no managed restarts. That is expected and is part of what the PoC measures (see §7).
6. Deploys
A small script per app, run over SSM RunCommand from a developer laptop (or CI later): pull the new image tag → restart the units in order → poll the health endpoint → report. No SSH, no new tooling. The tag deployed is whatever CI pushed for k8s staging, so the two deployments stay comparable during the parallel-run phase.
Recorded deviations (execution)
- Box SG ingress uses the IE edge's private IP
/32(172.31.4.137/32) rather than an SG reference — the peering is cross-VPC, and a security group cannot be referenced across the peering boundary the way §2 assumed. Breaks silently if the IE box is ever replaced at a new private IP; re-point the ingress rule then. - The web port is published on all interfaces, not the private IP only — §2 said "the box's private IP"; what's running binds
0.0.0.0. Exposure is nil in practice (the SG admits only the edge), but the defense-in-depth intent of §2 was dropped.
7. What the PoC deliberately surfaces (the roadmap fallout)
CARLOS gives these routes routing + TLS and nothing else. Every gap met is a named candidate roadmap item, and this list is the actual deliverable of the migration evaluation:
- No image/bundle release kind — a CARLOS release is one static binary; ExecStart is pinned to
/opt/carlos/live/%i(internal/caps/render.go). Running Rails as a first-class app needs a bundle/image release kind or an equivalent. - observed: confirmed — the gap is real, and the PoC boxes hand-roll docker + systemd unit files in its place, not a CARLOS release at all. - No attached-database primitive — the platform design spec already sketches
attachedengines (Postgres provisioned and bound by the platform,docs/superpowers/specs/2026-08-01-carlos-platform-design.md§storage); the objstore primitive (2026-08-10-object-storage-primitive-design.md) is the implementation template. - observed: no such primitive existed to reach for — dependency access was wired by hand (security-group ingress rules on the shared staging DB/cache SGs, plus connection env delivered separately); it worked, but nothing about it would survive a second app onboarding itself. - No config/env delivery to addr-upstream routes — the PoC hand-rolls SSM Parameter Store; a platform answer would ride the existing
appconfigrails. - observed: confirmed, and it cost a real mistake — the hand-rolled prune rule over-pruned a live key (PROMETHEUS_EXPORTER_PORT) from both apps' env on the first pass; caught by review, not by any safeguard the ad hoc delivery had. - No log shipping for external processes — journald-only on the PoC boxes; CARLOS logs stage 2 (unit tenants) is the relevant seam. - observed: confirmed — journald-only, no shipping. Adequate for the PoC's own diagnosis needs; not evidence either way for real ops load.
- No health/restart/metrics for off-box upstreams —
-unitstamping only works for processes on the edge box itself. - observed: confirmed — the deploy script itself carries the whole health gate (on-box HTTP probe with per-app path/header, then a report-only edge check); CARLOS supplies none of it for this route shape. - Architecture — the fleet is Graviton; the Rails images are amd64. A first-class Rails-on-CARLOS deployment means arm64 image builds (Buildkite multi-arch). Deferred deliberately: the PoC boxes are disposable, so they run x86 to match the images rather than the other way round. - observed: the x86 boxes ran the unmodified Buildkite amd64 images with no rebuild and no compatibility issue — the deferral cost nothing for the PoC itself; the arm64 gap for a real deployment stands as stated.
- High availability (added 2026-08-12, Paul's question). The PoC has deliberately little: one box per app, one AZ, edge proxying to one IP;
Restart=alwayscovers process death and nothing covers box death. The layers split cleanly into two owners, and nothing Tito-side waits on anything CARLOS-side: - Tito concerns (this migration's own work, plain AWS, no platform changes): EC2 auto-recovery on the boxes; N boxes per app behind an internal NLB with the edge's-addrpointed at the NLB (multi-AZ, health-checked failover, rolling zero-downtime deploys — and networking-trivial in prod, where the existing peering already routes the whole VPC); Classic's supercronic → sidekiq-ent so cron stops being a single-box constraint (Pro's already is, with Redis-backed single-fire); the hostname/host-authorization config. - CARLOS concerns (roadmap candidates, priced by whether Rails-on-CARLOS graduates from migration tactic to product): multi-upstream health-checkedaddrroutes at the edge — the platform-native replacement for the NLB rung (filed as platform #152); and the edge's own HA posture (standby/EIP failover), which is a whole-deployment question every CARLOS tenant shares — the Rails apps widen the blast radius of a riskgo.tito.ioalready carries, they don't create it (filed as platform #153). - Data HA is out of scope by construction: Postgres/MySQL/Valkey/ Memcached stay in RDS/Elasticache with their own multi-AZ story — the apps' state was never on k8s, so the migration never touches the layer where losing things actually hurts.
2026-08-11: staging is live on both apps (see tito/README.md's Rails PoC section in platform-infrastructure for current status). The edge host-authorization gap (classic 403, pro 404) is still open and is what blocks this route shape from serving a real page today — the PoC proved the plumbing, not a finished path to production traffic.
2026-08-11, later — two verification items closed on-box:
- Shared-queue replica behavior (Verification item 4): proven for Pro, organically. The pro box's sidekiq processed 35 real staging jobs in one observed hour (
ScheduledTasks::ExpireUnfulfilledAllocationsWorker, real JIDs in journald) — enqueued into the shared staging Redis by Pro's own sidekiq-enterprise cron and won by the box worker in competition with the k8s workers. Classic's staging queues were quiet over the same window, so its half still wants a deliberate enqueue. - Real-page serving: both apps serve correct full responses from the boxes when addressed by a hostname they already know (curl on-box with
Host:+X-Forwarded-Proto: https, exactly what the edge forwards):staging.pro.tito.io→ 200 with the real Tito Pro page;staging.tito.io→ 301 to canonicalstaging.ti.to;dashboard.staging.tito.io→ 302 into its SSO login flow. The 403/404 on the edge hostnames is therefore ONLY hostname configuration (Classic's host allowlist; a Pro tenant/alias for the hostname), not a serving gap — the remaining decision is which hostnames the parallel deployment should answer to, which is a migration-design question, not an infrastructure one.
2026-08-28 — hostname configuration closed; the edge hostnames serve the real apps. Both turned out to be env-only, applied in the boxes' own SSM params (which the k8s deployment never reads), so the change is box-scoped and reverts by reverting two values:
- Classic's 403 was stock Rails
ActionDispatch::HostAuthorization, allowlist fed entirely from*_HOSTenv vars inconfig/application.rb.HEALTHCHECK_HOSTis the one var with no second meaning anywhere in the app (noAppConfig.hostbranch, no route constraint, no throttle rule), so the box setsHEALTHCHECK_HOST=classic.platform.tito.ioand the deploy script's health probe follows it.ALTERNATIVE_PRIMARY_HOSTSwas rejected: it admits a host and then 301s every path toPRIMARY_HOST. Result:classic.platform.tito.io→ 302 into Tito ID SSO — Classic's real root behavior. Completing a sign-in through the new hostname would additionally need it in the staging ID service's OAuth redirect allowlist — shared state, deliberately untouched. - Pro's 404 was the custom-domain catch-all (
Custom::BaseControllerraising RecordNotFound for a host that is neither a configured platform host nor a tenant domain). The primary host is anywayconfig (UrlsConfig), ENV overurls.yml, so the box sets `URLSPRIMARY=pro.platform.tito.ioand the whole primary-domain route tree, CSP, ActionCable origins and URL generation follow.URLSSECONDARYwas rejected: the app 302s every secondary-host request to primary. Result:pro.platform.tito.io→ 200 with the real primary app. Known consequence, accepted as an honest cutover preview: the box's sidekiq shares staging's queue and builds absolute URLs from its ownURLSPRIMARY, so staging mails whose jobs run on the box carry workingpro.platform.tito.io` links.
The migration-design lesson: neither app needed code or shared-state changes to answer on a new hostname — a real cutover's hostname story is an env rollout, plus the ID service's OAuth redirect allowlist for Classic sign-in. Ops record: teamtito/carlos-infrastructure PR #10.
Out of scope
- Any platform code change (release kinds, primitives) — the PoC is precedent-composition only.
- Cutting any real traffic over from k8s; DNS for the apps' real customer domains is untouched.
- Production apply (written, not applied).
- Any image rebuilds, including arm64/multi-arch (recorded as roadmap fallout in §7 instead).
Verification
tofu plantargeted to the new resources — the tito tree carries known drift from open PRs and a full apply would strip live SMTP ingress (recorded intito/object-store.tf's commit message).- Boxes reachable via SSM; units running; env files 0600.
https://classic.platform.tito.ioandhttps://pro.platform.tito.ioserve the real staging apps with valid certs.- A staging background job enqueued through the k8s deployment is observed processed by a PoC-box worker (proves shared-queue replica behavior).
- One deploy exercised end-to-end over SSM (new tag → restart → healthy).
- A week running alongside k8s untouched; then the production-apply decision.