Skip to content

Deploy Forge central services to AWS ECS/Fargate - #1

Closed
bajtos wants to merge 52 commits into
mainfrom
forge-central-deployment
Closed

bajtos wants to merge 52 commits into
mainfrom
forge-central-deployment

Conversation

@bajtos

@bajtos bajtos commented Aug 11, 2026 •

Copy link
Copy Markdown
Member

Replace smelt's single-VM Docker Compose deployment for the five central services with Terraform-managed infrastructure: ECS/Fargate behind an ALB, one RDS Postgres instance with a database per service, and OpenBao as the secrets manager. Multiple stages are supported through a stage variable.

This pull request is intended as the first step toward the future staging & production deployments. It enables continuous deployment to a dev environment and is designed to support multiple stages, including production running on a different AWS account. I expect that we will incrementally improve this setup until it's ready to support our staging & production deployments too.

Architecture

Cloud/AWS native, but using non-AWS infra dependencies wherever possible - Postgres, OpenBao.

All services are running on ECS and can be scaled independently.

We are using as few AWS-specific components as possible, to make it easy to migrate to a different infrastructure provider in the future if desired.

  • SSM & KMS are used only to store service configuration secrets and to unseal OpenBao. Services don't access SSM directly; secrets are fetched from SSM by the infrastructure layer. We can improve the services to read secrets from OpenBao instead of config files & env vars, but it's not a priority now.
  • delegator does not support Postgres and requires DynamoDB instead. It should be easy to add support for Postgres, but not worth it now.
  • sprue requires an S3-compatible storage, and we use AWS S3 for simplicity. This is easy to swap for a different S3-compatible object storage if we migrate off AWS.

Secrets never touch a laptop or Terraform state

In fil-forge/smelt#11, I proposed minting keys on a developer's or operator's machine and storing them in 1Password. Here, a Go Lambda (cmd/forge-provision) runs inside the VPC, writes private material straight to SSM Parameter Store, and returns only DIDs, wallet addresses and database names.

Platform

  • Stores OpenBao's data in Postgres and seal it with KMS.
  • Gives hilt an AppRole scoped to forge/hilt/data/tenant/* instead of the Vault root token, which smelt tracks as debt.
  • Exposes OpenBao publicly, since rfc: regional security principles and key management deployment proposal fil-one/RFC#21 makes it the transit seal regional appliances authenticate against to unseal at boot.
  • Pins images by manifest digest rather than git SHA, which names the last commit instead of the code just built and collides against a dirty tree.

Funding the wallet

A fund phase signs the three FilecoinPay transactions inside AWS, so the payer key never leaves the account. Terraform never invokes it; make fund-payer shows a plan and requires confirmation.

Start reading here: README.md

bajtos added 3 commits August 11, 2026 21:15
Replace smelt's single-VM Docker Compose deployment for the five central
services with Terraform-managed infrastructure: ECS/Fargate behind an ALB,
one RDS Postgres instance with a database per service, and OpenBao as the
secrets manager. Supports multiple stages through a `stage` variable.

The substantial change from smelt is where secrets come from. smelt mints
keys on an operator's laptop and stores them in 1Password; here a Go Lambda
runs inside the VPC, writes private material straight to SSM Parameter
Store, and returns only DIDs, wallet addresses and database names. No
private key reaches Terraform state or a local disk.

- Port smelt's keygen: Ed25519 identities, secp256k1 wallets with the three
  serializations each consumer expects, and the UCAN startup proofs, now
  issued in-process through ucantool's new ucandelegate API rather than by
  shelling out to its CLI.
- Never regenerate an existing secret. Writes use Overwrite=false, so
  re-applying cannot abandon a funded wallet or change a registered DID.
- Namespace parameters per service, so a task execution role reads only
  /forge/<stage>/<service>/* and a compromised task cannot reach another
  service's credentials.
- Store OpenBao's data in Postgres and seal it with KMS. Fargate has no
  durable local disk, and KMS removes the unseal key, the 1Password item
  holding it, and the sidecar that applied it.
- Give hilt an AppRole scoped to forge/hilt/data/tenant/* instead of the
  Vault root token, which smelt tracks as debt.
- Expose OpenBao publicly, since fil-one/RFC#21 makes it the transit seal
  regional appliances authenticate against to unseal at boot.
- Add a fund phase that signs the three FilecoinPay transactions inside
  AWS, so the payer key never leaves the account. Terraform never invokes
  it; `make fund-payer` shows a plan and requires confirmation.
- Pin images by manifest digest rather than git SHA, which names the last
  commit instead of the code just built and collides against a dirty tree.

Use ucantool's identity helpers rather than libforge's: libforge formats
the signer into its error text with %s, and an ed25519 signer is a []byte
with no String method, so a marshalling failure renders the raw private key
into a logged error. Reported separately; sprue, hilt and swarf still
depend on it.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
@linear

linear Bot commented Aug 11, 2026

Copy link
Copy Markdown

FIL-883

@bajtos
bajtos requested review from frrist and a lite review from Copilot and removed request for Copilot August 11, 2026 19:24
@bajtos
bajtos requested review from frrist and hannahhoward and removed request for frrist and hannahhoward August 11, 2026 19:26
bajtos added 17 commits August 12, 2026 09:45
ECR repositories are regional and Lambda pulls an image only from ECR
in the same region as the function, so a stage outside us-east-2 cannot
reuse the one repository the bootstrap workspace created. The workspace
name was hardcoded in the cloud block, leaving no way to stand up a
second one.

Move the repository into a modules/provision-ecr module and reduce the
env directory to a per-region wrapper naming its own workspace and
provider region. Adding a region is now a copy of that directory with
two names changed.

The HCP cloud block cannot interpolate a workspace name, and the
tag-based alternative moves the region out of the repo into workspace
variables where a reviewer cannot see it. A directory per region keeps
it explicit and matches how the stages are already laid out.

The us-east-2 workspace has to be renamed to
infra-central-bootstrap-us-east-2 in HCP Terraform before the next
apply, which preserves its state.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5[1m]
The five root modules floored at 1.9, several releases behind. 1.15 is
the current stable line; 1.16 is still in beta, so it would force
pre-release builds on everyone.

Nothing in the tree needs a 1.15-only feature yet. The point is to keep
the floor close to what the workspaces will actually run, so a
constraint failure surfaces at init rather than mid-apply.

Every root module plans and applies through HCP Terraform. Pin each
workspace to 1.15.x once it exists, instead of leaving it tracking
latest, so the 1.16 upgrade is a scheduled change.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
The cloud blocks named "fil-forge", which is the GitHub org, not the HCP
one. No workspace would have resolved under that name.

The two tfe_outputs data sources in the apps modules name the org too,
so they move with the cloud blocks. Had they been left behind, both apps
workspaces would fail to read the platform outputs they build on.

The remaining fil-forge references in the tree are GitHub: Go module
paths and two README links. Those stay.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
The provision Lambda image lived in `forge-provision`, a flat name with
no room for the service images that will move off GHCR. It is now
`forge-central/provision`: one repository per image under a shared
prefix, which is what makes per-image push permissions, lifecycle
policies and tag mutability possible. A single repository holding every
service, distinguished by tag, forfeits all three.

The module is `modules/ecr` rather than `modules/provision-ecr`, so the
repositories to come have somewhere to live, and its output is
namespaced as `provision_repository_url`.

`make publish` also wrote a `sha-<short>` tag that nothing read, since
Terraform deploys the Lambda by digest. It now pushes by digest with no
tag at all, which lets the repository be IMMUTABLE.

That change makes the lifecycle rule dangerous. It expired untagged
images after 14 days, where untagged used to mean orphaned by a
re-pushed tag. Untagged now means every image, including the digest a
stage pins, and a Lambda whose image is deleted from ECR stops working
and cannot be updated. ECR's rule language cannot express "keep
anything a stage pins", so the rule is gone and pruning is manual. The
images are a few tens of MB.

Applying this replaces the repository, and Terraform cannot destroy one
that holds images. Drop the two old resources from the bootstrap state,
apply, delete `forge-provision` with `--force`, then `make publish` and
repoint each stage's provision_image_repository_url.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Nothing in the config said which account a workspace applies into. An
apply under the wrong AWS_PROFILE succeeded, created a real stage in the
wrong place, and recorded it in HCP state. Every provider block now
carries allowed_account_ids, so a mismatch fails at plan time and names
the account it found.

The ids live in modules/constants, a module that creates nothing and
returns literals. Root modules cannot share a variable, and a provider
block may reference module outputs as long as they do not come from a
resource.

provision_image_repository_url was the second place each account id was
written, by hand, into terraform.tfvars. Both stages now derive it from
their account, their region and the repository name, all three from
modules/constants, and the variable is gone. That is the same
derivation `make publish` already does from the calling identity, and it
states the invariant that made copying error-prone: a Lambda pulls only
from its own account's registry in its own region, so those two values
are the whole address. Prod's REPLACE_ME resolves as a side effect.

One bootstrap workspace per region cannot serve both accounts for the
same reason, so bootstrap/<region> becomes bootstrap/<account>/<region>
with a workspace and an account guard each.

In HCP Terraform: create infra-central-bootstrap-{nonprod,prod}-us-east-2
with credentials for their accounts, delete
infra-central-bootstrap-us-east-2, and drop any workspace variable named
provision_image_repository_url, which is now undeclared.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
The HCP workspace names and the Project default tag both derive from the
repository name, which is becoming forge-central.

The workspaces have to be renamed in HCP Terraform as well; Terraform
does not create them and a plan against a missing workspace fails.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5[1m]
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
The dev platform stage plans in HCP, which sees only what is in version
control, so a digest written to a gitignored file on the operator's
machine was never applied anywhere. Track image.auto.tfvars instead and
say so in the runbook: `make publish` writes it, the operator commits it,
the HCP run picks it up. Promotion to prod stays a deliberate copy of the
digest into the prod terraform.tfvars.

Also make `make publish` create a docker-container builder on first use.
Docker Desktop's default builder cannot push by digest and cannot
cross-build for arm64, so the target failed on a stock install.

Document how each part is deployed today, now that there is no CI: the
bootstrap workspaces and the image are handled by an operator locally,
and the platform and apps paths are still open.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
The ecs-service module decided whether to create the task policy by
comparing the policy JSON against null. OpenBao's seal policy names the
KMS key created in the same apply, so that JSON is unknown while
planning, and a count that depends on it cannot be evaluated: the first
plan of a stage failed with "Invalid count argument".

Take a map keyed by policy name instead. for_each needs its keys known
at plan time but tolerates unknown values, so a service gets its policy
even when the permissions name resources the same apply creates.

Also drop failure_threshold from health_check_custom_config. AWS ignores
the value and always applies 1, so it only produced a deprecation
warning. The block itself stays: ECS reports task health through it, and
it cannot be added to an existing Cloud Map service.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5[1m]
Both dev workspaces track main in HCP with auto-apply on, so a merge
reaches the stage without anyone running Terraform, and a run trigger
keeps apps behind the platform outputs it reads. The runbook still told
operators to run terraform apply from a laptop, and the deployment table
still called the path unsettled.

Record what a new stage's workspaces need, since none of it is visible
in the repository: remote execution, the OIDC variable set, trigger
patterns covering terraform/modules, and the run trigger on platform.

Prod is described in one sentence and in the future tense. It does not
exist yet and its process is likely to change.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5[1m]
Dev followed rolling tags, which was defensible when a deploy was an
operator running apply. It is not now: HCP applies dev on every commit
to main, so with a tag what dev runs depends on when a task last
restarted rather than on what was merged.

Take a digest per service instead, validated to start with sha256: so a
tag pasted here fails at plan time rather than at task start. The dev
stage pins what each repository's main tag pointed at today; all six are
multi-arch indexes carrying the linux/arm64 the tasks run on.

envs/prod/apps/terraform.tfvars loses its pinned tags without gaining
digests. The tags named a scheme the service repos no longer publish,
and the digests that replace them have to come from real images. Prod is
not deployed, so a plan that fails for want of them is the right
outcome.

Also record two runbook steps that the move to remote runs broke:
rotating an identity and rotating hilt's OpenBao credential both need a
provision phase re-run, and neither trigger is reachable from a
VCS-driven run.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5[1m]
The stage prefixed its AWS resources with forge-, which says nothing
about which Forge deployment they belong to. This repository is the
central one, as against the regional nodes, so the names should say so.

Resource names take fc- rather than the full forge-central. A target
group name is capped at 32 characters and has to hold
<prefix>-<stage>-signing-service, which forge-central-staging- already
overflows. Paths and namespaces are nowhere near a limit, so they spell
forge-central out, matching the ECR repository that already did.

- resources:  forge-<stage>-*        -> fc-<stage>-*
- parameters: /forge/<stage>/*       -> /forge-central/<stage>/*
- log groups: /forge/<stage>/<svc>   -> /forge-central/<stage>/<svc>
- namespace:  forge.internal         -> forge-central.internal
- KV mount:   forge/hilt             -> forge-central/hilt
- command:    cmd/forge-provision    -> cmd/provision

The KV mount moves on both sides at once, in vaultinit and in hilt's
HILT_VAULT_HASHICORP_MOUNT, since hilt's path builder is mount-relative.

Applying this re-mints a stage. The parameter path is the identity of
every secret, so seed finds an empty namespace and generates fresh keys,
DIDs and wallets; the old payer keeps its funds at its old address and
the new one needs funding from the faucet. Parameters under the old path
are not Terraform-managed and outlive the apply, so retire them once the
stage is healthy. Only dev is affected: prod has never been applied.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5[1m]
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
A dev stage sets protect_stateful_resources = false so that it can be
destroyed, but three things ignored the flag and blocked or outlived the
destroy. S3 refused to delete a bucket holding objects, the delegator's
tables paid for continuous backups a disposable stage does not want, and
the KMS key sat in PendingDeletion for 30 days afterwards.

Versioning goes with them. It guards against losing something
irreplaceable, and nothing in these buckets is: sprue writes the objects
and can write them again. Keeping it would mean a lifecycle rule to stop
noncurrent versions accumulating, and it made a bucket undeletable until
every version had been purged a page at a time.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5[1m]
bajtos added 24 commits August 12, 2026 15:24
openbao crash looped on every task start, three lines per attempt and a
new attempt every seventy seconds:

  mkdir: can't create directory '/run/forge': Permission denied
  /bin/sh: can't create /run/forge/openbao.hcl: nonexistent directory
  error loading configuration from /run/forge/openbao.hcl

The ephemeral volume behind secret_dir was created only for a service
that declares secret_files, and openbao declares none: its config is a
template around the Postgres DSN rather than a secret in its own right,
so its own shell_command assembles the file. With no mount, secret_dir
belongs to the image, the task is not root, and the write is denied.

The mount is what a service needs, and writing the files is only one
reason to want it. writable_secret_dir asks for it directly, so the
requirement is visible at the call site rather than implied by an empty
map somewhere else.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Mounting the ephemeral volume let openbao create the directory and then
denied the write into it:

  /bin/sh: can't create /run/forge/openbao.hcl: Permission denied

A Fargate ephemeral volume mounts root-owned and takes no uid or gid
option, so no mount can be written by a container that is not root. That
is not an openbao quirk. The delegator and the signing service both end
their images with USER nobody and would have failed the same way on the
first deploy that exercised their secret_files; sprue works only because
its image never sets USER and runs as root.

So drop the volume and write into /tmp, the one directory an image is
relied on to leave world-writable. The container filesystem carries the
same AES-256 encryption and dies with the task, so the volume was
holding a guarantee that /tmp already gives. umask 077 and chmod 400
still keep every file readable only by the process that wrote it.

This replaces writable_secret_dir, added an hour ago for openbao. The
knob asked for a mount, and a mount is the thing that cannot work.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
The first apply of a stage waited four minutes and then timed out
against a server that was behaving correctly:

  openbao is not serving yet  attempt=30
    reason="openbao is sealed; check that the KMS seal key is reachable"

An OpenBao that has never been initialised reports itself sealed,
because there is no barrier to unseal yet. WaitForUnsealed read that as
a reason to keep waiting, and the call that would have initialised it
runs after the wait returns, so the phase waited on a state only it
could produce.

Uninitialised now counts as ready. The sealed branch keeps its KMS
message, which is accurate once initialised is true: a barrier that
exists and stays shut means the seal key did not open it.

That moves the wait rather than removing it. sys/init returns once the
barrier is written and the seal opens it a moment later, so the phase
now waits for the barrier it just created before it mounts anything.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
The provision Lambda could not reach OpenBao. Its packets were dropped
rather than refused:

  dial tcp 10.20.33.229:8200: connect: connection timed out

aws_security_group.service declares its rules inline, and inline blocks
are the authoritative set for a group: anything added by a separate
aws_security_group_rule is removed the next time the group is applied.
So the standalone rule and the group have been undoing each other on
every run, which is what the recurring "aws_security_group.service: Plan
to update" in each plan was. Whichever ran last decided whether the
Lambda could reach port 8200, and this time it was the removal.

Moving the rule inline settles it. The comment on the old resource said
a separate rule avoided a cycle between the two group definitions, but
there is no cycle to avoid: the lambda group holds one egress rule to
0.0.0.0/0 and refers to no other group.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Replacing a Cloud Map service deadlocks against the task it serves.
Terraform destroys the old service before it updates the ECS service
that points at it, so the registration is still there when the delete
runs, and Cloud Map refuses:

  ResourceInUse: Service contains registered instances; delete the
  instances before deleting the service

Scaling the ECS service to zero clears it, but only until something
starts a task again, and the apply itself is what brings one back. Three
runs failed this way on the same service id.

force_destroy has the provider deregister the instances as part of the
delete, so the replacement no longer depends on catching an empty
window.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Every plan since 66c8db2 has scheduled a replacement of the Cloud Map
service, and each replacement landed in the state that forces the next
one. The service reads back with no HealthCheckCustomConfig at all while
the configuration declares the block, and the block is ForceNew, so the
only move Terraform has is to recreate.

An empty block is the cause. Without failure_threshold the provider
sends no custom health config on CreateService, AWS records none, and
the read finds none. Stating the value makes the block real. It is inert
either way, since AWS always applies 1 and the attribute is deprecated,
so the cost is one deprecation warning per plan against a service that
stops being recreated on every apply.

One more replacement follows this, which force_destroy now lets finish.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
OpenBao 2.x refuses audit devices created over the API, so the vault
phase failed with a 400 as soon as it reached the stdout device. The
device has to come from an audit stanza in the server config instead,
which needs stdout confirmed as a sink that cannot block a Fargate task
and a check that 2.6.0 applies the stanza at first boot. Both are
written up under Planned work; until then OpenBao runs without an audit
log.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Two settings decide whether the apps workspace works, and neither was in
the stage checklist.

Auto-apply run triggers is independent of auto-apply, so a workspace
with auto-apply on still parks a triggered run in front of a confirm
button. That is the platform-only change, which is the case nobody is
watching.

The state read needs a token, not a sharing grant. Remote state access
controls do not cover tfe_outputs: it goes through the API as the tfe
provider, so the workspace needs TFE_TOKEN with read access to the
platform workspace's outputs.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
The tfe_outputs data source marks its whole `values` map sensitive, and
the taint spreads to everything derived from it, so the apps workspace
could not export service_urls or service_dids without declaring them
sensitive. Nothing the platform workspace exports there is a secret:
ARNs, subnet ids, hostnames. Read `nonsensitive_values` instead of
hiding two useful outputs behind `sensitive = true`.

Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Assisted-by: Claude:claude-opus-5[1m]
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
An apply wires up everything the central services need, so it reads as
though a stage is complete once both workspaces are green. The first
Piri/Ingot appliance pointed at one needs five things nothing here
provides: three registration writes against the delegator, sprue and
hilt, and two UCAN proofs that cannot be minted from nothing because each
depends on a key that does not exist until the appliance does.

Written down now rather than during the first onboarding, together with
the two constraints that shape the answer: there is no shell to run
sprue's and hilt's admin CLIs in, and the proof traffic runs in both
directions, so onboarding is a request carrying the appliance's DIDs
rather than another idempotent phase.

Assisted-by: Claude:claude-opus-5[1m]
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Nothing said whether an apply worked. Confirming a stage meant opening
five hostnames by hand, and the check that matters most is the one an
operator is least likely to think of: sprue, hilt and swarf mint an
ephemeral identity when no key is supplied and report themselves healthy
either way, so a service whose DID has silently changed underneath
everything registered against it looks exactly like a working one.

Two checks per public service, over public HTTPS. A 200 from the health
path covers the whole ingress route at once, and the did.json check
compares the published id against did:web:<hostname>, which is what
proves the key arrived from SSM.

Needs neither AWS credentials nor a TFE token: the hostname suffix comes
from the stage's committed platform/terraform.tfvars, and the expected
DID is built from the hostname. Services are probed concurrently, since
a task that accepts the connection and never replies waits out the whole
timeout.

Two gaps the script names in its own output rather than passing over.
plc has no public hostname, so nothing here reaches it. And
piri-signing-service serves no document at the did:web it was given, so
its DID resolves nowhere; the service_dids description said otherwise
and is corrected here.

Assisted-by: Claude:claude-opus-5[1m]
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
The delegator has never started. Every task died before its process ran,
with runc reporting an invalid environment variable containing a nul
byte and naming nothing else.

Both of its startup proofs are bare DAG-CBOR delegations, which is what
its delegation.Decode reads, and binary. Every proof reaches its
consumer as an SSM-injected environment variable that the entrypoint
wrapper writes to a file, and an environment variable cannot carry a
NUL. The value never got that far intact anyway: PutParameter goes over
JSON, which replaces invalid UTF-8 with U+FFFD.

So a binary proof is stored base64-encoded and decoded on the way to the
file. hilt's proof is a base64+gzip container, already text, and is
still stored as ucantool writes it, trailing newline included.

A stage seeded before this needs both parameters deleted and the seed
phase re-run, because an existing proof is left alone. The README says
which two and why.

Assisted-by: Claude:claude-opus-5[1m]
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
sprue could not start: creating its agent-message bucket failed on a DNS
lookup of fc-dev-agent-message-<account>.minio, a host that exists in
smelt's compose file and nowhere in the VPC.

Its storage.s3.endpoint carries a default of http://minio:9000, so
leaving the variable out does not select real S3, it selects a MinIO
that was never deployed. Setting it empty is what reaches AWS, and the
empty value survives because sprue calls viper's AllowEmptyEnv.

Nothing else changes: the buckets exist, the task role already grants
the ListBucket that sprue's HeadBucket probe needs, and CreateBucket is
reached only when that probe fails.

Assisted-by: Claude:claude-opus-5[1m]
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
plc crashed on startup with

  no pg_hba.conf entry for host "10.20.54.142", user "plc",
  database "plc", no encryption

Its credentials are a JSON blob rather than a URL, and the blob carried
no sslmode, so plc built a plaintext connection string. The instance
runs Postgres 16, whose default parameter group sets rds.force_ssl, and
RDS rejects an unencrypted connection with a pg_hba error that names no
other cause. Every other service reaches the same instance over a DSN
with sslmode=require.

no-verify rather than require because plc connects through
node-postgres, which verifies the server certificate against Node's own
trust store. That store carries no Amazon RDS root and the image has no
bundle to point NODE_EXTRA_CA_CERTS at, so require fails the handshake
instead. The connection is encrypted either way, which is what
force_ssl asks for.

No migration: db-creds-json is rewritten on every seed run.

Assisted-by: Claude:claude-opus-5[1m]
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Publishes the provision image carrying the base64 proof encoding and
plc's sslmode, and forces the seed phase to run with it.

Both are needed. aws_lambda_invocation re-invokes only when its input
changes, and the function's image is not part of that input, so a new
digest on its own would leave the seed phase untouched.

The delegator's two proof parameters were deleted by hand beforehand.
The seed phase leaves an existing proof alone, so it re-issues them,
this time base64-encoded. plc's db-creds-json is rewritten every run and
needed nothing.

Assisted-by: Claude:claude-opus-5[1m]
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Its store describes both tables before serving, and with no endpoint
override a failure there is fatal: an absent table means the wrong
account, so it refuses to create one and stops. The task role granted
only item-level actions, so the check failed with AccessDenied and the
service never finished wiring its dependency graph.

Assisted-by: Claude:claude-opus-5[1m]
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Every module sat directly under terraform/modules, so a workspace could
not name its dependencies in a trigger pattern without matching
terraform/modules/**/* and planning both workspaces on every module
change.

Modules used only by the platform workspace now live under
modules/platform, and the two that cross the boundary (ecs-service,
constants) live under modules/shared. Each workspace can point its
triggers at its own composite module plus shared.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Every plan warns that health_check_custom_config.failure_threshold is
deprecated. The warning is expected: AWS fixed the Cloud Map custom
health check wait at one 30-second interval and deprecated the
parameter, provider v6.0.0 mirrored that, and 1ccea93 already found
that omitting the value makes the provider create the service with no
custom health config, forcing a replacement on every plan.

Record the upstream state in the routing.tf comment and a README sharp
edge, with links to the issues that will eventually retire the warning:

- hashicorp/terraform-provider-aws#44285 asks the provider to stop
  warning on the value AWS forces anyway
- hashicorp/terraform-provider-aws#43428 lets the argument be removed
  without replacing the service
- hashicorp/terraform-provider-aws#44291 got the deprecation documented

Assisted-by: Claude:claude-fable-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Sections had accumulated in the order they were written, so standing up
a stage meant jumping between the Stages section and the Runbook, and
the Terraform module tree was printed in both.

Concepts now come first and every procedure lives in the Runbook in the
order an operator meets it. "Adding a stage" and "A personal sandbox
stage" move there; the two "Why..." notes on UCAN proofs become
subsections of the identity rotation they explain.

Corrections along the way:

- the intro counted five services where four later passages counted six
- Related described smelt#11 as the Compose deployment being replaced,
  which is the repository, not that PR
- "Prod is not set up yet" contradicted envs/prod/ in the layout; the
  config is committed, the HCP workspaces are not created
- indexer and etracker appeared once with no introduction
- Planned work claimed every item needs a change outside this repo

Added a table of contents, a prerequisites list covering cast and
buildx, the CloudWatch log group names, the HCP organization and
project, and Planned work entries for RDS restore, alerting and cost.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
Signed-off-by: Miroslav Bajtoš <oss@bajtos.net>
bajtos added a commit to fil-one/RFC that referenced this pull request Aug 17, 2026
<!-- rfc-preview-link:start -->
[📖
Preview](https://github.com/fil-one/RFC/blob/forge-central-deployment-strategy/rfcs/2026-08-forge-central-deployment-strategy.md)
<!-- rfc-preview-link:end -->

## Summary

Forge consists of two independent sets of services: Forge-Central
contains centrally managed set of services operated by the FilOne team,
FilOne Appliance contains the software stack powering regional nodes.

This pull request proposes deployment strategy for the Central stack.
It's a complement for [rfc#19](#19),
which describes the deployment strategy for FilOne Appliance.

## Proofs of Concept

- Terraform version: fil-forge/infra-central#1
- Pulumi version: fil-forge/infra-central#23
@bajtos

bajtos commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Closing in favour of Stack 22 (#2 to #20).

@bajtos bajtos closed this Aug 17, 2026
@bajtos bajtos mentioned this pull request Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant