* 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]>
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-identityworks) - 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.yamlon the instance (via SSM session), then run the upgrade command — and keepimage_tagin 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:
- Back up the identity key (above) and take a final snapshot if you may return.
- Remove the
prevent_destroyline fromaws_ebs_volume.datainmain.tf. 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/ssmkey by default; setssm_kms_key_idto encrypt with a customer-managed KMS key instead (the instance role is grantedkms:Decrypton that key automatically). - IMDSv2 is required; metrics port is closed unless
metrics_ingress_cidris 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).