Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,23 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4
with:
role-to-assume: arn:aws:iam::516608939870:role/github-actions-terraform
aws-region: us-east-2

- name: Login to ECR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
registry: 516608939870.dkr.ecr.us-east-2.amazonaws.com

- id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
with:
images: ghcr.io/keplerops/ground-control
images: |
ghcr.io/keplerops/ground-control
516608939870.dkr.ecr.us-east-2.amazonaws.com/ground-control
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
Expand Down
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.59.0] - 2026-03-18
## [0.60.0] - 2026-03-18

### Added

Expand All @@ -15,7 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
volume with cloud-init bootstrapping (Docker, Tailscale, compose)
- Terraform `backup` module: S3 bucket for pg_dump backups (30-day lifecycle),
DLM policy for daily EBS snapshots (7-day retention)
- Production Docker Compose (`deploy/docker/docker-compose.prod.yml`) — GHCR
- ECR container registry for deployment images — EC2 pulls via IAM role (no
tokens needed), CI pushes to both GHCR and ECR
- Production Docker Compose (`deploy/docker/docker-compose.prod.yml`) — ECR
image, EBS bind mounts, no Redis, JVM memory caps
- Automated deployment: CI pushes to `main` trigger deploy to EC2 via SSM
`SendCommand` after smoke test passes — no manual SSH needed
Expand All @@ -34,9 +36,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Terraform `environments/dev` rewritten to wire compute + networking + backup
+ secrets modules (was RDS + networking + secrets)
- Bootstrap IAM policy updated: replaced RDS permissions with EC2, IAM instance
profile, S3 backup bucket, DLM, and SSM SendCommand permissions
profile, S3 backup bucket, DLM, SSM SendCommand, and ECR permissions
- CI workflow (`ci.yml`): added `deploy` job that auto-deploys to EC2 on
push to `main`, added `id-token: write` permission for OIDC
push to `main`, added `id-token: write` permission for OIDC, added ECR
push alongside GHCR
- Deployment docs updated with AWS deployment section

### Fixed
Expand Down
43 changes: 43 additions & 0 deletions deploy/terraform/bootstrap/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,49 @@ resource "aws_iam_role_policy" "github_actions_terraform" {
"arn:aws:iam::*:instance-profile/groundcontrol-*",
]
},
{
Sid = "ECRManagement"
Effect = "Allow"
Action = [
"ecr:CreateRepository",
"ecr:DeleteRepository",
"ecr:DescribeRepositories",
"ecr:ListTagsForResource",
"ecr:TagResource",
"ecr:UntagResource",
"ecr:PutLifecyclePolicy",
"ecr:GetLifecyclePolicy",
"ecr:DeleteLifecyclePolicy",
"ecr:SetRepositoryPolicy",
"ecr:GetRepositoryPolicy",
"ecr:DeleteRepositoryPolicy",
"ecr:PutImageScanningConfiguration",
"ecr:PutImageTagMutability",
]
Resource = "arn:aws:ecr:${var.aws_region}:*:repository/ground-control"
},
{
Sid = "ECRPush"
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken",
]
Resource = "*"
},
{
Sid = "ECRImagePush"
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
]
Resource = "arn:aws:ecr:${var.aws_region}:*:repository/ground-control"
},
{
Sid = "S3BackupBucketManagement"
Effect = "Allow"
Expand Down
35 changes: 34 additions & 1 deletion deploy/terraform/environments/dev/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,38 @@ module "backup" {
bucket_name = var.backup_bucket_name
}

# --- Container Registry (ECR) ---

resource "aws_ecr_repository" "app" {
name = "ground-control"
image_tag_mutability = "MUTABLE"

image_scanning_configuration {
scan_on_push = false
}
}

resource "aws_ecr_lifecycle_policy" "app" {
repository = aws_ecr_repository.app.name

policy = jsonencode({
rules = [
{
rulePriority = 1
description = "Keep last 10 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 10
}
action = {
type = "expire"
}
}
]
})
}

# --- Compute (EC2 + IAM + EBS) ---

module "compute" {
Expand All @@ -41,10 +73,11 @@ module "compute" {
instance_type = var.instance_type
data_volume_size = var.data_volume_size
tailscale_hostname = var.tailscale_hostname
gc_image = var.gc_image
gc_image = "${aws_ecr_repository.app.repository_url}:latest"

backup_bucket_name = module.backup.bucket_name
backup_bucket_arn = module.backup.bucket_arn
ecr_registry_url = split("/", aws_ecr_repository.app.repository_url)[0]

ssm_tailscale_key = module.secrets.tailscale_auth_key_name
ssm_db_password = module.secrets.db_password_name
Expand Down
5 changes: 5 additions & 0 deletions deploy/terraform/environments/dev/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,8 @@ output "ssm_db_password" {
description = "SSM parameter name for database password"
value = module.secrets.db_password_name
}

output "ecr_repository_url" {
description = "ECR repository URL for Ground Control images"
value = aws_ecr_repository.app.repository_url
}
3 changes: 0 additions & 3 deletions deploy/terraform/environments/dev/terraform.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,5 @@ data_volume_size = 20
# Tailscale
tailscale_hostname = "gc-dev"

# Docker image (set to specific tag for pinned deploys)
gc_image = "ghcr.io/keplerops/ground-control:latest"

# S3 backup bucket
backup_bucket_name = "groundcontrol-backups-catalyst-dev"
6 changes: 0 additions & 6 deletions deploy/terraform/environments/dev/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,6 @@ variable "tailscale_hostname" {
default = "gc-dev"
}

variable "gc_image" {
description = "Docker image for Ground Control backend"
type = string
default = "ghcr.io/keplerops/ground-control:latest"
}

variable "backup_bucket_name" {
description = "S3 bucket name for database backups"
type = string
Expand Down
30 changes: 30 additions & 0 deletions deploy/terraform/modules/compute/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,35 @@ resource "aws_iam_role_policy" "s3_backup" {
})
}

resource "aws_iam_role_policy" "ecr_pull" {
name = "ecr-pull"
role = aws_iam_role.instance.id

policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ECRAuth"
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken",
]
Resource = "*"
},
{
Sid = "ECRPull"
Effect = "Allow"
Action = [
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchCheckLayerAvailability",
]
Resource = "arn:aws:ecr:${var.aws_region}:*:repository/ground-control"
}
]
})
}

resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.instance.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
Expand Down Expand Up @@ -132,6 +161,7 @@ resource "aws_instance" "this" {
ssm_tailscale_key = var.ssm_tailscale_key
ssm_db_password = var.ssm_db_password
tailscale_hostname = var.tailscale_hostname
ecr_registry_url = var.ecr_registry_url
backup_bucket = var.backup_bucket_name
gc_image = var.gc_image
gc_database_user = var.gc_database_user
Expand Down
10 changes: 10 additions & 0 deletions deploy/terraform/modules/compute/user-data.sh.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,16 @@ services:
restart: unless-stopped
COMPOSEEOF

# --- ECR login helper (values baked by Terraform) ---
cat > /opt/gc/ecr-login.sh <<ECREOF
#!/bin/bash
aws ecr get-login-password --region "${aws_region}" | docker login --username AWS --password-stdin "${ecr_registry_url}"
ECREOF
chmod +x /opt/gc/ecr-login.sh

cd /opt/gc
/opt/gc/ecr-login.sh
docker compose pull
docker compose up -d

# --- Backup script ---
Expand Down Expand Up @@ -153,6 +162,7 @@ cat > /opt/gc/deploy.sh <<'DEPLOYEOF'
#!/bin/bash
set -euo pipefail
cd /opt/gc
source /opt/gc/ecr-login.sh
docker compose pull
docker compose up -d
echo "Waiting for health check..."
Expand Down
5 changes: 5 additions & 0 deletions deploy/terraform/modules/compute/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ variable "gc_image" {
default = "ghcr.io/keplerops/ground-control:latest"
}

variable "ecr_registry_url" {
description = "ECR registry URL (e.g. 516608939870.dkr.ecr.us-east-2.amazonaws.com)"
type = string
}

variable "gc_database_user" {
description = "PostgreSQL database username"
type = string
Expand Down
2 changes: 1 addition & 1 deletion deploy/terraform/modules/networking/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ data "aws_subnets" "default" {

resource "aws_security_group" "instance" {
name = "${var.name_prefix}-instance"
description = "Zero-ingress security group for ${var.name_prefix} all access via Tailscale"
description = "Zero-ingress security group for ${var.name_prefix} - all access via Tailscale"
vpc_id = local.vpc_id
}

Expand Down
Loading