Skip to content

Commit bdab8dc

Browse files
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 <openclaude@gitlawb.com>
1 parent 0a49bb7 commit bdab8dc

10 files changed

Lines changed: 262 additions & 15 deletions

File tree

‎infra/aws/README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,5 +151,7 @@ them up in the EC2 console if unwanted. The Elastic IP is released on destroy.
151151
`ssm_kms_key_id` to encrypt with a customer-managed KMS key instead (the
152152
instance role is granted `kms:Decrypt` on that key automatically).
153153
- IMDSv2 is required; metrics port is closed unless `metrics_ingress_cidr` is set.
154-
- The node serves plain HTTP on 7545. For TLS, put a DNS name + proxy
155-
(ALB/CloudFront/Caddy) in front and set `public_url` accordingly.
154+
- The node itself serves plain HTTP on 7545. Set `domain_name` (with DNS
155+
pointing at the Elastic IP) to run a Caddy sidecar with automatic Let's
156+
Encrypt TLS on 443 (+ http→https redirect on 80) — matching the Fly nodes.
157+
Certs persist on the data volume (`/mnt/data/caddy`).

‎infra/aws/compose.yaml.tftpl‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@
44
# `$${VAR}` entries are resolved by docker compose from /opt/gitlawb/.env at runtime.
55

66
services:
7+
%{ if domain_name != "" ~}
8+
caddy:
9+
image: caddy:2-alpine
10+
depends_on:
11+
node:
12+
condition: service_healthy
13+
ports:
14+
- "80:80" # redirects to https
15+
- "443:443" # Let's Encrypt cert, auto-renewed; certs persist on the EBS volume
16+
command: caddy reverse-proxy --from ${domain_name} --to node:${gitlawb_port}
17+
volumes:
18+
- /mnt/data/caddy:/data
19+
restart: unless-stopped
20+
21+
%{ endif ~}
22+
%{ if db_host == "" ~}
723
postgres:
824
image: postgres:16-alpine
925
environment:
@@ -19,11 +35,14 @@ services:
1935
retries: 5
2036
restart: unless-stopped
2137

38+
%{ endif ~}
2239
node:
2340
image: ${image_repo}:${image_tag}
41+
%{ if db_host == "" ~}
2442
depends_on:
2543
postgres:
2644
condition: service_healthy
45+
%{ endif ~}
2746
ports:
2847
- "${gitlawb_port}:${gitlawb_port}" # HTTP API + git smart-HTTP
2948
- "${p2p_port}:${p2p_port}/udp" # libp2p QUIC
@@ -33,7 +52,7 @@ services:
3352
volumes:
3453
- /mnt/data/node:/data
3554
environment:
36-
DATABASE_URL: postgresql://${pg_user}:$${POSTGRES_PASSWORD}@postgres:5432/${pg_db}
55+
DATABASE_URL: postgresql://${pg_user}:$${POSTGRES_PASSWORD}@${db_host != "" ? db_host : "postgres"}:5432/${pg_db}
3756
GITLAWB_HOST: 0.0.0.0
3857
GITLAWB_PORT: "${gitlawb_port}"
3958
GITLAWB_P2P_PORT: "${p2p_port}"

‎infra/aws/main.tf‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,17 @@ resource "aws_security_group" "node" {
185185
cidr_blocks = [var.p2p_ingress_cidr]
186186
}
187187

188+
dynamic "ingress" {
189+
for_each = var.domain_name != "" ? [80, 443] : []
190+
content {
191+
description = "Caddy HTTP/HTTPS (TLS for ${var.domain_name})"
192+
from_port = ingress.value
193+
to_port = ingress.value
194+
protocol = "tcp"
195+
cidr_blocks = ["0.0.0.0/0"]
196+
}
197+
}
198+
188199
dynamic "ingress" {
189200
for_each = local.expose_metrics ? [1] : []
190201
content {
@@ -272,11 +283,17 @@ locals {
272283
}
273284

274285
locals {
275-
public_url = var.public_url != "" ? var.public_url : "http://${aws_eip.node.public_ip}:${var.gitlawb_port}"
286+
public_url = (
287+
var.public_url != "" ? var.public_url :
288+
var.domain_name != "" ? "https://${var.domain_name}" :
289+
"http://${aws_eip.node.public_ip}:${var.gitlawb_port}"
290+
)
276291

277292
compose_yaml = templatefile("${path.module}/compose.yaml.tftpl", {
278293
image_repo = local.image_repo
279294
image_tag = var.image_tag
295+
domain_name = var.domain_name
296+
db_host = var.use_rds ? aws_db_instance.node[0].address : ""
280297
gitlawb_port = var.gitlawb_port
281298
p2p_port = var.gitlawb_p2p_port
282299
metrics_port = var.metrics_port

‎infra/aws/monitoring.tf‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Alerting (enabled when alert_email is set): SNS email + CloudWatch alarms
2+
# on external API health (Route 53 health check), EC2 status, CPU, and RDS
3+
# free storage. The email subscription must be confirmed once via the link
4+
# SNS sends.
5+
6+
locals {
7+
alarms_enabled = var.alert_email != ""
8+
}
9+
10+
resource "aws_sns_topic" "alerts" {
11+
count = local.alarms_enabled ? 1 : 0
12+
name = "${var.name_prefix}-alerts"
13+
tags = local.common_tags
14+
}
15+
16+
resource "aws_sns_topic_subscription" "email" {
17+
count = local.alarms_enabled ? 1 : 0
18+
topic_arn = aws_sns_topic.alerts[0].arn
19+
protocol = "email"
20+
endpoint = var.alert_email
21+
}
22+
23+
# External HTTPS /health probe — catches everything the EC2-level checks
24+
# can't (container down, caddy broken, cert expired, DNS wrong).
25+
resource "aws_route53_health_check" "api" {
26+
count = local.alarms_enabled && var.domain_name != "" ? 1 : 0
27+
fqdn = var.domain_name
28+
port = 443
29+
type = "HTTPS"
30+
resource_path = "/health"
31+
request_interval = 30
32+
failure_threshold = 3
33+
tags = merge(local.common_tags, { Name = "${var.name_prefix}-api" })
34+
}
35+
36+
# Route 53 health check metrics are only published in us-east-1; this module
37+
# already runs there by default.
38+
resource "aws_cloudwatch_metric_alarm" "api_health" {
39+
count = local.alarms_enabled && var.domain_name != "" ? 1 : 0
40+
alarm_name = "${var.name_prefix}-api-health"
41+
alarm_description = "https://${var.domain_name}/health is failing"
42+
namespace = "AWS/Route53"
43+
metric_name = "HealthCheckStatus"
44+
dimensions = { HealthCheckId = aws_route53_health_check.api[0].id }
45+
statistic = "Minimum"
46+
period = 60
47+
evaluation_periods = 2
48+
threshold = 1
49+
comparison_operator = "LessThanThreshold"
50+
treat_missing_data = "breaching"
51+
alarm_actions = [aws_sns_topic.alerts[0].arn]
52+
ok_actions = [aws_sns_topic.alerts[0].arn]
53+
tags = local.common_tags
54+
}
55+
56+
resource "aws_cloudwatch_metric_alarm" "status_check" {
57+
count = local.alarms_enabled ? 1 : 0
58+
alarm_name = "${var.name_prefix}-status-check"
59+
alarm_description = "EC2 status checks failing on the node instance"
60+
namespace = "AWS/EC2"
61+
metric_name = "StatusCheckFailed"
62+
dimensions = { InstanceId = aws_instance.node.id }
63+
statistic = "Maximum"
64+
period = 60
65+
evaluation_periods = 2
66+
threshold = 1
67+
comparison_operator = "GreaterThanOrEqualToThreshold"
68+
alarm_actions = [aws_sns_topic.alerts[0].arn]
69+
ok_actions = [aws_sns_topic.alerts[0].arn]
70+
tags = local.common_tags
71+
}
72+
73+
resource "aws_cloudwatch_metric_alarm" "cpu_high" {
74+
count = local.alarms_enabled ? 1 : 0
75+
alarm_name = "${var.name_prefix}-cpu-high"
76+
alarm_description = "Node instance CPU above 80% for 15 minutes"
77+
namespace = "AWS/EC2"
78+
metric_name = "CPUUtilization"
79+
dimensions = { InstanceId = aws_instance.node.id }
80+
statistic = "Average"
81+
period = 300
82+
evaluation_periods = 3
83+
threshold = 80
84+
comparison_operator = "GreaterThanThreshold"
85+
alarm_actions = [aws_sns_topic.alerts[0].arn]
86+
ok_actions = [aws_sns_topic.alerts[0].arn]
87+
tags = local.common_tags
88+
}
89+
90+
resource "aws_cloudwatch_metric_alarm" "rds_storage_low" {
91+
count = local.alarms_enabled && var.use_rds ? 1 : 0
92+
alarm_name = "${var.name_prefix}-rds-storage-low"
93+
alarm_description = "RDS free storage below 2 GB"
94+
namespace = "AWS/RDS"
95+
metric_name = "FreeStorageSpace"
96+
dimensions = { DBInstanceIdentifier = aws_db_instance.node[0].identifier }
97+
statistic = "Minimum"
98+
period = 300
99+
evaluation_periods = 2
100+
threshold = 2147483648
101+
comparison_operator = "LessThanThreshold"
102+
alarm_actions = [aws_sns_topic.alerts[0].arn]
103+
ok_actions = [aws_sns_topic.alerts[0].arn]
104+
tags = local.common_tags
105+
}

‎infra/aws/outputs.tf‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ output "ecr_repository_url" {
2323
value = try(aws_ecr_repository.node[0].repository_url, null)
2424
}
2525

26+
output "rds_endpoint" {
27+
description = "RDS Postgres endpoint (null unless use_rds)"
28+
value = try(aws_db_instance.node[0].address, null)
29+
}
30+
2631
output "postgres_password_ssm_param" {
2732
description = "SSM parameter holding the postgres password (value not shown)"
2833
value = aws_ssm_parameter.postgres_password.name

‎infra/aws/rds.tf‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Optional managed Postgres (use_rds = true): the database survives instance
2+
# loss independently, with automated backups / point-in-time recovery. The
3+
# in-Docker postgres service is dropped from the compose stack when enabled.
4+
5+
data "aws_subnets" "node_vpc" {
6+
filter {
7+
name = "vpc-id"
8+
values = [data.aws_subnet.selected.vpc_id]
9+
}
10+
}
11+
12+
resource "aws_db_subnet_group" "node" {
13+
count = var.use_rds ? 1 : 0
14+
name = "${var.name_prefix}-db"
15+
subnet_ids = data.aws_subnets.node_vpc.ids
16+
tags = local.common_tags
17+
}
18+
19+
resource "aws_security_group" "rds" {
20+
count = var.use_rds ? 1 : 0
21+
name = "${var.name_prefix}-rds"
22+
description = "Postgres, reachable only from the node instance"
23+
vpc_id = data.aws_subnet.selected.vpc_id
24+
tags = local.common_tags
25+
26+
ingress {
27+
description = "Postgres from the node"
28+
from_port = 5432
29+
to_port = 5432
30+
protocol = "tcp"
31+
security_groups = [aws_security_group.node.id]
32+
}
33+
}
34+
35+
resource "aws_db_instance" "node" {
36+
count = var.use_rds ? 1 : 0
37+
identifier = "${var.name_prefix}-db"
38+
engine = "postgres"
39+
engine_version = "16"
40+
instance_class = var.rds_instance_class
41+
allocated_storage = var.rds_allocated_storage_gb
42+
storage_type = "gp3"
43+
storage_encrypted = true
44+
45+
db_name = var.postgres_db
46+
username = var.postgres_user
47+
password = random_password.postgres.result
48+
49+
db_subnet_group_name = aws_db_subnet_group.node[0].name
50+
vpc_security_group_ids = [aws_security_group.rds[0].id]
51+
52+
backup_retention_period = var.rds_backup_retention_days
53+
deletion_protection = true
54+
skip_final_snapshot = false
55+
final_snapshot_identifier = "${var.name_prefix}-db-final"
56+
apply_immediately = true
57+
tags = local.common_tags
58+
}

‎infra/aws/terraform.tfvars.example‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ instance_type = "t4g.small" # ARM/Graviton; node image is multi-arch
1616
# snapshot_retain_count = 7
1717

1818
# --- Node configuration (defaults mirror infra/fly/fly.toml) ---------------
19-
# Set to your real DNS name for production; defaults to http://<elastic-ip>:7545
19+
# DNS name pointing at the Elastic IP: enables a Caddy sidecar with automatic
20+
# HTTPS (Let's Encrypt) on 80/443, and public_url becomes https://<domain>
21+
# domain_name = "node.example.com"
22+
23+
# Explicit override; rarely needed when domain_name is set.
24+
# Defaults to https://<domain_name>, else http://<elastic-ip>:7545
2025
# public_url = "https://node.example.com"
2126

2227
# bootstrap_peers = "https://node.gitlawb.com,https://node2.gitlawb.com,https://node3.gitlawb.com"

‎infra/aws/user-data.sh.tftpl‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,12 @@ mkdir -p /mnt/data
5757
grep -q 'LABEL=gitlawb-data' /etc/fstab || \
5858
echo 'LABEL=gitlawb-data /mnt/data ext4 defaults,nofail 0 2' >> /etc/fstab
5959
mount -a
60+
# Grow the filesystem if the EBS volume was enlarged (no-op otherwise)
61+
resize2fs "$DATA_DEV" || true
6062

6163
# Node container runs as uid/gid 1000 (gitlawb). Postgres manages its own dir.
62-
mkdir -p /mnt/data/node/repos /mnt/data/node/keys /mnt/data/postgres
64+
# caddy/ holds TLS certs (only used when a domain is configured).
65+
mkdir -p /mnt/data/node/repos /mnt/data/node/keys /mnt/data/postgres /mnt/data/caddy
6366
chown -R 1000:1000 /mnt/data/node
6467

6568
# ---------------------------------------------------------------------------

‎infra/aws/variables.tf‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ variable "ssh_key_name" {
137137
# Node configuration (defaults mirror infra/fly/fly.toml)
138138
# ---------------------------------------------------------------------------
139139

140+
variable "domain_name" {
141+
description = "DNS name pointing at the Elastic IP. When set, a Caddy sidecar terminates TLS on 80/443 with an auto-renewed Let's Encrypt cert, and public_url defaults to https://<domain_name>."
142+
type = string
143+
default = ""
144+
}
145+
140146
variable "public_url" {
141147
description = "Public URL of this node (GITLAWB_PUBLIC_URL). Leave empty to default to http://<elastic-ip>:<port> after apply — set a real DNS name for production."
142148
type = string
@@ -171,6 +177,36 @@ variable "ssm_kms_key_id" {
171177
# Postgres
172178
# ---------------------------------------------------------------------------
173179

180+
variable "use_rds" {
181+
description = "Run Postgres in RDS instead of a container on the instance. The DB then survives instance loss with automated backups."
182+
type = bool
183+
default = false
184+
}
185+
186+
variable "rds_instance_class" {
187+
description = "RDS instance class (only used when use_rds)"
188+
type = string
189+
default = "db.t4g.micro"
190+
}
191+
192+
variable "rds_allocated_storage_gb" {
193+
description = "RDS storage in GB (only used when use_rds)"
194+
type = number
195+
default = 20
196+
}
197+
198+
variable "rds_backup_retention_days" {
199+
description = "RDS automated backup retention (only used when use_rds)"
200+
type = number
201+
default = 7
202+
}
203+
204+
variable "alert_email" {
205+
description = "Email for CloudWatch alarm notifications via SNS (confirm the subscription email once). Empty = no alarms."
206+
type = string
207+
default = ""
208+
}
209+
174210
variable "postgres_user" {
175211
description = "Postgres user for the node database"
176212
type = string

‎infra/aws/versions.tf‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,12 @@ terraform {
1212
}
1313
}
1414

15-
# Remote state (optional). Create the bucket first, then uncomment and run
16-
# `terraform init -migrate-state`. See README.md "Remote state".
17-
#
18-
# backend "s3" {
19-
# bucket = "gitlawb-terraform-state"
20-
# key = "infra/aws/terraform.tfstate"
21-
# region = "us-east-1"
22-
# use_lockfile = true
23-
# }
15+
backend "s3" {
16+
bucket = "gitlawb-tfstate-874373491455"
17+
key = "infra/aws/terraform.tfstate"
18+
region = "us-east-1"
19+
use_lockfile = true
20+
}
2421
}
2522

2623
provider "aws" {

0 commit comments

Comments
 (0)