Files
Kevin CodexandOpenClaude 7a8b6c4974 Aws ecr image (#55)
* feat(infra): optional private ECR repo for the node image

create_ecr_repo = true provisions an ECR repository in-account and
switches the instance to pull from it: the instance role gets
AmazonEC2ContainerRegistryReadOnly and user-data configures
amazon-ecr-credential-helper, so first-boot pulls and SSM upgrades
authenticate via the role (no expiring login tokens). README documents
the local build-and-push flow.

Motivation: ghcr.io/gitlawb/node is not anonymously pullable (403),
which left first boot unable to start the stack.

Co-Authored-By: OpenClaude <[email protected]>

* feat(infra): HTTPS via Caddy + production tier (RDS, alarms, S3 state)

- domain_name: Caddy sidecar terminates TLS on 80/443 with auto-renewed
  Let's Encrypt certs (persisted on the data volume); public_url
  defaults to https://<domain>; SG opens 80/443 — matches the Fly nodes
- use_rds: managed Postgres (db.t4g.micro, encrypted, 7-day PITR,
  deletion protection) replaces the in-container postgres; DB security
  group only admits the node instance
- alert_email: SNS email alerts wired to CloudWatch alarms — external
  HTTPS /health via Route53 health check, EC2 status checks, CPU,
  RDS free storage
- user-data grows the filesystem when the EBS volume is enlarged
- terraform state moved to S3 (versioned, KMS-encrypted, public-blocked
  bucket) with lockfile locking (requires terraform >= 1.10)

Co-Authored-By: OpenClaude <[email protected]>

---------

Co-authored-by: OpenClaude <[email protected]>
2026-06-18 22:05:50 +08:00
..
2026-06-18 22:05:50 +08:00
2026-06-05 22:38:53 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00
2026-06-18 22:05:50 +08:00

gitlawb node on AWS (Terraform)

Single EC2 instance running the published node image + Postgres via Docker compose, with a persistent encrypted EBS volume, Elastic IP, SSM access, and daily snapshots.

Elastic IP ──► EC2 t4g.small (Amazon Linux 2023, arm64)
  7545/tcp       docker compose:
  7546/udp        ├─ node      (ghcr.io/gitlawb/node, pulled — not built)
                  └─ postgres:16-alpine
                 EBS gp3 volume mounted at /mnt/data
                  ├─ node/   → container /data (repos + identity key)
                  └─ postgres/ → postgres data dir

Prerequisites

  • Terraform ≥ 1.6
  • AWS credentials configured (aws sts get-caller-identity works)
  • AWS CLI + Session Manager plugin (for shell access)
  • A default VPC in the target region (or pass subnet_id)

Quick start

cd infra/aws
cp terraform.tfvars.example terraform.tfvars   # edit: public_url at minimum
terraform init
terraform plan
terraform apply    # ⚠ creates billable resources (~$25/mo: EC2 + EBS + snapshots)

After apply (~3-5 min for first boot to pull images and start):

curl "$(terraform output -raw api_url)/health"

⚠ First boot: back up the identity key

The node generates /data/keys/identity.pem on first start — it defines the node's DID. Losing it permanently changes the node's identity. Back it up immediately:

$(terraform output -raw ssm_session_command)
# in the session:
sudo cat /mnt/data/node/keys/identity.pem

Store the key somewhere safe (password manager / offline). The volume's prevent_destroy guard and daily DLM snapshots protect against accidents, but are not a substitute for an offline backup.

Shell access

SSM Session Manager — no SSH port, no keys to manage:

$(terraform output -raw ssm_session_command)

Bootstrap log: /var/log/gitlawb-bootstrap.log. Stack lives in /opt/gitlawb (docker compose ps, docker compose logs node).

SSH is off by default; set ssh_ingress_cidr + ssh_key_name if you need it.

Building and pushing the image (ECR mode)

With create_ecr_repo = true the instance pulls from a private ECR repo in this account instead of an external registry (the instance role gets pull rights via the amazon-ecr-credential-helper). Build from the repo root — arm64 to match t4g (native on Apple Silicon):

ECR_URL=$(terraform -chdir=infra/aws output -raw ecr_repository_url)
aws ecr get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin "${ECR_URL%%/*}"
docker build --platform linux/arm64 -t "$ECR_URL:latest" .
docker push "$ECR_URL:latest"

Then run the upgrade command (below) to roll it out.

Upgrading the node

User-data only runs at first boot, so upgrades go through SSM:

$(terraform output -raw upgrade_command)

This runs docker compose pull && docker compose up -d on the instance.

  • With image_tag = "latest" (default) that picks up the newest release.
  • With a pinned tag, first edit the tag in /opt/gitlawb/compose.yaml on the instance (via SSM session), then run the upgrade command — and keep image_tag in terraform.tfvars in sync so a future instance replacement boots the same version.

Replace the instance itself (OS/AMI/instance-type changes) with terraform apply -replace=aws_instance.node — the data volume reattaches and /data (including the identity key) survives.

Changing configuration

User-data only runs at first boot, and the instance ignores user_data drift (ignore_changes), so editing terraform.tfvars values that feed the bootstrap (bootstrap_peers, public_url, integrations, image_tag) does not affect a running instance on terraform apply. To roll out such changes, either edit /opt/gitlawb/.env on the instance (SSM session, then docker compose up -d), or replace the instance:

terraform apply -replace=aws_instance.node

The data volume reattaches; repos, postgres data, and the identity key survive.

Remote state (optional)

Local state is the default. To move state to S3: create a versioned bucket, uncomment the backend "s3" block in versions.tf, then:

terraform init -migrate-state

Teardown

terraform destroy will fail on the data volume by design (prevent_destroy). To tear everything down:

  1. Back up the identity key (above) and take a final snapshot if you may return.
  2. Remove the prevent_destroy line from aws_ebs_volume.data in main.tf.
  3. terraform destroy.

Note: DLM snapshots created by the policy are not deleted by destroy — clean them up in the EC2 console if unwanted. The Elastic IP is released on destroy.

Security notes

  • Postgres password: generated by Terraform, stored as an SSM SecureString, fetched at boot via the instance profile — never in user-data or state-free files on disk (only in /opt/gitlawb/.env, mode 600). It IS in Terraform state — treat state as sensitive (another reason for the S3 backend).
  • Sensitive optional vars (operator_private_key, pinata_jwt, s3_access_key_id, s3_secret_access_key) follow the same SSM path.
  • SSM secrets use the AWS-managed aws/ssm key by default; set ssm_kms_key_id to encrypt with a customer-managed KMS key instead (the instance role is granted kms:Decrypt on that key automatically).
  • IMDSv2 is required; metrics port is closed unless metrics_ingress_cidr is set.
  • The node itself serves plain HTTP on 7545. Set domain_name (with DNS pointing at the Elastic IP) to run a Caddy sidecar with automatic Let's Encrypt TLS on 443 (+ http→https redirect on 80) — matching the Fly nodes. Certs persist on the data volume (/mnt/data/caddy).