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
43 changes: 43 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Git
.git
.gitignore

# CI/CD and infrastructure
terraform/
.github/

# Development and test files
node_modules/
e2e/
test/
playwright-report/
playwright/
test-results/

# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
*~

# Logs and temp
log/*
tmp/*

# OS files
.DS_Store

# Documentation
docs/
*.md

# Environment files
.env
.env.*

# Storage (databases and uploads are created at runtime)
storage/*

# Playwright MCP
.playwright-mcp/
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,11 @@
playwright-report/
playwright/.auth/
test-results/

# Terraform
terraform/.terraform/
terraform/.terraform.lock.hcl
terraform/*.tfstate
terraform/*.tfstate.backup
terraform/terraform.tfvars
terraform/.terraform.tfstate.lock.info
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.3.5
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
FROM docker.io/library/ruby:$RUBY_VERSION-slim as base

# Rails app lives here
WORKDIR /rails
Expand Down
82 changes: 82 additions & 0 deletions terraform/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail

# Deploy Dill to GCP Cloud Run
#
# Prerequisites:
# - gcloud CLI installed and authenticated (gcloud auth login)
# - Docker installed
# - Terraform installed
# - A GCP project with billing enabled (free tier is sufficient)
#
# Usage:
# cd terraform
# ./deploy.sh

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(dirname "$SCRIPT_DIR")"

# Load terraform variables
if [ ! -f "$SCRIPT_DIR/terraform.tfvars" ]; then
echo "Error: terraform/terraform.tfvars not found."
echo "Copy terraform.tfvars.example to terraform.tfvars and fill in your values."
exit 1
fi

# Initialize Terraform and read variables using terraform console to avoid brittle parsing
(
cd "$SCRIPT_DIR"
terraform init -input=false >/dev/null
)

PROJECT_ID=$(cd "$SCRIPT_DIR" && terraform console -input=false -var-file="terraform.tfvars" -execute='var.project_id' | tr -d '\r')
REGION=$(cd "$SCRIPT_DIR" && terraform console -input=false -var-file="terraform.tfvars" -execute='var.region' | tr -d '\r')
APP_NAME=$(cd "$SCRIPT_DIR" && terraform console -input=false -var-file="terraform.tfvars" -execute='try(var.app_name, "dill")' | tr -d '\r' || echo "dill")
if [ -z "$APP_NAME" ] || [ "$APP_NAME" = "dill" ]; then
APP_NAME="dill"
fi

DOCKER_TAG="${REGION}-docker.pkg.dev/${PROJECT_ID}/${APP_NAME}/${APP_NAME}:latest"

echo "==> Deploying ${APP_NAME} to GCP Cloud Run"
echo " Project: ${PROJECT_ID}"
echo " Region: ${REGION}"
echo " Image: ${DOCKER_TAG}"
echo ""

# Step 1: Provision infrastructure with Terraform
echo "==> Step 1: Running Terraform to provision infrastructure..."
cd "$SCRIPT_DIR"
terraform init
terraform apply -auto-approve
cd "$APP_DIR"

# Step 2: Configure Docker to push to Artifact Registry
echo ""
echo "==> Step 2: Configuring Docker authentication for Artifact Registry..."
gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet

# Step 3: Build the Docker image
echo ""
echo "==> Step 3: Building Docker image..."
docker build -t "$DOCKER_TAG" "$APP_DIR"

# Step 4: Push the image to Artifact Registry
echo ""
echo "==> Step 4: Pushing image to Artifact Registry..."
docker push "$DOCKER_TAG"

# Step 5: Deploy to Cloud Run (update the service to use the new image)
echo ""
echo "==> Step 5: Deploying new image to Cloud Run..."
gcloud run services update "$APP_NAME" \
--region "$REGION" \
--image "$DOCKER_TAG" \
--project "$PROJECT_ID" \
--quiet
Comment on lines +69 to +76
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deploy script updates the Cloud Run service with gcloud run services update after Terraform creates it. This will cause configuration drift from Terraform state and makes future terraform apply runs potentially revert or conflict with the deployed image. Consider making the image tag a Terraform variable and updating it via Terraform, or add a Terraform lifecycle { ignore_changes = [template[0].containers[0].image] } if gcloud is intended to own image updates.

Copilot uses AI. Check for mistakes.

# Print the service URL
echo ""
echo "==> Deployment complete!"
SERVICE_URL=$(gcloud run services describe "$APP_NAME" --region "$REGION" --project "$PROJECT_ID" --format='value(status.url)')
echo " Application URL: ${SERVICE_URL}"
176 changes: 176 additions & 0 deletions terraform/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
terraform {
required_version = ">= 1.5"

required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}

provider "google" {
project = var.project_id
region = var.region
}

# Enable required GCP APIs
resource "google_project_service" "apis" {
for_each = toset([
"run.googleapis.com",
"artifactregistry.googleapis.com",
"cloudbuild.googleapis.com",
"secretmanager.googleapis.com",
])

project = var.project_id
service = each.value
disable_on_destroy = false
}

# Artifact Registry repository for Docker images
resource "google_artifact_registry_repository" "app" {
location = var.region
repository_id = var.app_name
format = "DOCKER"
description = "Docker repository for ${var.app_name}"

depends_on = [google_project_service.apis]
}

# Store the Rails master key in Secret Manager
resource "google_secret_manager_secret" "rails_master_key" {
secret_id = "${var.app_name}-rails-master-key"

replication {
auto {}
}

depends_on = [google_project_service.apis]
}

resource "google_secret_manager_secret_version" "rails_master_key" {
secret = google_secret_manager_secret.rails_master_key.id
secret_data = var.rails_master_key
}
Comment on lines +52 to +55
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

google_secret_manager_secret_version.secret_data will store the Rails master key value in the Terraform state file in plaintext (even if the input variable is marked sensitive). This is a significant secret-leak risk for anyone with access to state (local disk, CI artifacts, remote backend). Prefer creating the Secret in Terraform but adding versions outside Terraform (e.g., gcloud secrets versions add), or use a secured/encrypted remote backend with strict access controls and avoid committing/retaining local state.

Suggested change
resource "google_secret_manager_secret_version" "rails_master_key" {
secret = google_secret_manager_secret.rails_master_key.id
secret_data = var.rails_master_key
}
# NOTE: Secret versions (actual Rails master key values) should be
# created outside Terraform (e.g., via `gcloud secrets versions add`)
# to avoid storing secret data in Terraform state.

Copilot uses AI. Check for mistakes.

# Service account for Cloud Run
resource "google_service_account" "cloud_run" {
account_id = "${var.app_name}-run-sa"
display_name = "${var.app_name} Cloud Run Service Account"
}

# Grant the service account access to read the secret
resource "google_secret_manager_secret_iam_member" "secret_access" {
secret_id = google_secret_manager_secret.rails_master_key.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloud_run.email}"
}

# Cloud Run service
resource "google_cloud_run_v2_service" "app" {
name = var.app_name
location = var.region

deletion_protection = false

template {
service_account = google_service_account.cloud_run.email

# Scale to zero when idle (free tier friendly)
scaling {
min_instance_count = 0
max_instance_count = 1
}

# Keep costs at zero — use smallest resource allocation
containers {
image = "${var.region}-docker.pkg.dev/${var.project_id}/${var.app_name}/${var.app_name}:latest"

ports {
container_port = 80
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cloud Run container is configured to use port 80, but the Docker image runs as a non-root user (UID 1000). Binding to privileged ports (<1024) will fail at runtime, so the service likely won’t start. Use an unprivileged port (typically 8080) and let the app read PORT (already in config/puma.rb).

Suggested change
container_port = 80
container_port = 8080

Copilot uses AI. Check for mistakes.
}

resources {
limits = {
cpu = "1"
memory = "512Mi"
}
cpu_idle = true # CPU is only allocated during request processing
}

env {
name = "RAILS_ENV"
value = "production"
}

env {
name = "DISABLE_SSL"
value = "true" # Cloud Run handles SSL termination
}

env {
name = "RAILS_LOG_TO_STDOUT"
value = "1"
}

env {
name = "RAILS_MASTER_KEY"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.rails_master_key.secret_id
version = "latest"
}
}
}

# Startup probe — Rails may need time to boot
startup_probe {
initial_delay_seconds = 5
timeout_seconds = 3
period_seconds = 5
failure_threshold = 10
http_get {
path = "/up"
}
}

# Liveness probe
liveness_probe {
http_get {
path = "/up"
}
}
}
}

depends_on = [
google_project_service.apis,
google_artifact_registry_repository.app,
google_secret_manager_secret_version.rails_master_key,
]
}

# Make the Cloud Run service publicly accessible
resource "google_cloud_run_v2_service_iam_member" "public" {
project = google_cloud_run_v2_service.app.project
location = google_cloud_run_v2_service.app.location
name = google_cloud_run_v2_service.app.name
role = "roles/run.invoker"
member = "allUsers"
}
Comment on lines +155 to +161
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unconditionally makes the Cloud Run service publicly invokable by allUsers. If this app is not intended to be fully public, this should be gated behind a variable (e.g., public_access = true/false) or use a more restrictive principal, otherwise it’s easy to accidentally expose internal environments.

Copilot uses AI. Check for mistakes.

# Optional: Custom domain mapping
resource "google_cloud_run_domain_mapping" "custom_domain" {
count = var.domain != "" ? 1 : 0
location = var.region
name = var.domain

metadata {
namespace = var.project_id
}

spec {
route_name = google_cloud_run_v2_service.app.name
}
}
14 changes: 14 additions & 0 deletions terraform/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
output "service_url" {
description = "The URL of the deployed Cloud Run service"
value = google_cloud_run_v2_service.app.uri
}

output "artifact_registry_repository" {
description = "The Artifact Registry repository URL for pushing Docker images"
value = "${var.region}-docker.pkg.dev/${var.project_id}/${var.app_name}"
}

output "docker_image_tag" {
description = "The full Docker image tag to use when pushing"
value = "${var.region}-docker.pkg.dev/${var.project_id}/${var.app_name}/${var.app_name}:latest"
}
7 changes: 7 additions & 0 deletions terraform/terraform.tfvars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copy this file to terraform.tfvars and fill in your values
project_id = "your-gcp-project-id"
region = "us-central1"
rails_master_key = "your-rails-master-key-from-config/master.key"

# Optional: set a custom domain
# domain = "dill.example.com"
28 changes: 28 additions & 0 deletions terraform/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
variable "project_id" {
description = "The GCP project ID"
type = string
}

variable "region" {
description = "The GCP region for deployment"
type = string
default = "us-central1"
}

variable "app_name" {
description = "Application name used for resource naming"
type = string
default = "dill"
}

variable "rails_master_key" {
description = "Rails master key for decrypting credentials (from config/master.key)"
type = string
sensitive = true
}

variable "domain" {
description = "Custom domain for the application (optional, leave empty to use Cloud Run default URL)"
type = string
default = ""
}