Move backend from VM to Cloud Run and remove Load Balancer

Both to save lots of $$$
This commit is contained in:
MartinBraquet
2026-05-11 16:01:12 +02:00
parent 0d5f1cdae3
commit 9caac159e1
6 changed files with 138 additions and 337 deletions

View File

@@ -1,7 +1,7 @@
name: API Release
on:
push:
branches: [main, master]
branches: [ main, master ]
paths:
- 'backend/api/package.json'
- '.github/workflows/cd-api.yml'
@@ -70,17 +70,6 @@ jobs:
- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker us-west1-docker.pkg.dev --quiet
- name: Install Tofu (Terraform)
run: |
LATEST=https://github.com/opentofu/opentofu/releases/download/v1.10.5/tofu_1.10.5_linux_amd64.zip
curl -LO "$LATEST"
unzip -o tofu_*_linux_amd64.zip
sudo mv tofu /usr/local/bin/
rm tofu_*_linux_amd64.zip
echo "OpenTofu version: $(tofu version)"
cd backend/api
tofu init
- name: Run deploy script
run: |
chmod +x backend/api/deploy-api.sh

View File

@@ -4,7 +4,7 @@ FROM node:20-alpine
WORKDIR /usr/src/app
# Install PM2 globally
RUN yarn global add pm2
# RUN yarn global add pm2
# Fet dependencies in for efficient docker layering
COPY dist/package.json dist/yarn.lock ./
@@ -21,13 +21,15 @@ RUN npm list || true
COPY dist ./
# Copy the PM2 ecosystem configuration
COPY ecosystem.config.js ./
# COPY ecosystem.config.js ./
ENV PORT=80
EXPOSE 80/tcp
#ENV PORT=80
#EXPOSE 80/tcp
# EXPOSE 8090/tcp
# EXPOSE 8091/tcp
# EXPOSE 8092/tcp
# Use PM2 to run the application with the ecosystem config
CMD ["pm2-runtime", "ecosystem.config.js"]
# Use PM2 to run the application with the ecosystem config (was only for VM, not cloud run)
#CMD ["pm2-runtime", "ecosystem.config.js"]
CMD ["node", "-r", "tsconfig-paths/register", "backend/api/lib/serve.js"]

View File

@@ -1,14 +1,4 @@
#!/bin/bash
# steps to deploy new version to GCP:
# 1. build new docker image & upload to Google
# 2. create a new GCP instance template with the new docker image
# 3. tell the GCP 'backend service' for the API to update to the new template
# 4. a. GCP creates a new instance with the new template
# b. wait for the new instance to be healthy (serving TCP connections)
# c. route new connections to the new instance
# d. delete the old instance
set -e
cd "$(dirname "$0")"
@@ -45,28 +35,21 @@ TIMESTAMP=$(date +"%s")
IMAGE_TAG="${TIMESTAMP}-${GIT_REVISION}"
IMAGE_URL="${REGION}-docker.pkg.dev/${PROJECT}/builds/${SERVICE_NAME}:${IMAGE_TAG}"
echo "🚀 Deploying ${SERVICE_NAME} to ${ENV} ($(date "+%Y-%m-%d %I:%M:%S %p"))"
echo "🚀 Building & Pushing Image..."
yarn build
gcloud auth print-access-token | docker login -u oauth2accesstoken --password-stdin us-west1-docker.pkg.dev
docker build . --tag ${IMAGE_URL} --platform linux/amd64 --progress=plain
echo "docker push ${IMAGE_URL}"
gcloud auth print-access-token | docker login -u oauth2accesstoken --password-stdin ${REGION}-docker.pkg.dev
gcloud auth configure-docker ${REGION}-docker.pkg.dev --quiet
docker build . --tag ${IMAGE_URL} --platform linux/amd64
docker push ${IMAGE_URL}
export TF_VAR_image_url=$IMAGE_URL
export TF_VAR_env=$ENV
tofu apply -auto-approve
# Update Cloud Run (The fast way)
# This keeps all the Terraform-defined settings (env vars, memory, etc.)
# but simply swaps the container image.
gcloud run deploy ${SERVICE_NAME} \
--image ${IMAGE_URL} \
--region ${REGION} \
--platform managed \
--quiet
#INSTANCE_NAME=$(gcloud compute instances list \
# --filter="zone:(us-west1-c)" \
# --sort-by="~creationTimestamp" \
# --format="value(name)" \
# --limit=1)
#SERVICE_ACCOUNT_EMAIL=$(gcloud compute instances describe ${INSTANCE_NAME} \
# --zone us-west1-c \
# --format="value(serviceAccounts.email)")
#gcloud projects add-iam-policy-binding ${PROJECT} \
# --member="serviceAccount:$SERVICE_ACCOUNT_EMAIL" \
# --role="roles/artifactregistry.reader"
echo "✅ Deployment complete! Image: ${IMAGE_URL}"
echo "Custom Domain: https://api.compassmeet.com"
echo "✅ Code updated on Cloud Run!"

54
backend/api/deploy-init-api.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
source ../../.env
ENV=${1:-prod}
# Config
REGION="us-west1"
ZONE="us-west1-b"
PROJECT="compass-130ba"
SERVICE_NAME="api"
GIT_REVISION=$(git rev-parse --short HEAD)
GIT_COMMIT_DATE=$(git log -1 --format=%ci)
GIT_COMMIT_AUTHOR=$(git log -1 --format='%an')
GIT_COMMIT_MESSAGE=$(git log -1 --format='%s')
echo "Git commit message: ${GIT_COMMIT_MESSAGE}"
cat > metadata.json << EOF
{
"git": {
"revision": "${GIT_REVISION}",
"commitDate": "${GIT_COMMIT_DATE}",
"author": "${GIT_COMMIT_AUTHOR}",
"message": "${GIT_COMMIT_MESSAGE}"
}
}
EOF
TIMESTAMP=$(date +"%s")
IMAGE_TAG="${TIMESTAMP}-${GIT_REVISION}"
IMAGE_URL="${REGION}-docker.pkg.dev/${PROJECT}/builds/${SERVICE_NAME}:${IMAGE_TAG}"
echo "🚀 Building & Pushing Image..."
yarn build
gcloud auth print-access-token | docker login -u oauth2accesstoken --password-stdin ${REGION}-docker.pkg.dev
docker build . --tag ${IMAGE_URL} --platform linux/amd64
docker push ${IMAGE_URL}
echo "Infrastructure Update..."
export TF_VAR_image_url=$IMAGE_URL
export TF_VAR_env=$ENV
tofu apply -auto-approve
# Get the new URL just in case
SERVICE_URL=$(gcloud run services describe ${SERVICE_NAME} --platform managed --region ${REGION} --format 'value(status.url)')
echo "✅ Deployed to Cloud Run!"
echo "Service URL: ${SERVICE_URL}"
echo "Custom Domain: https://api.compassmeet.com"

View File

@@ -1,7 +1,7 @@
# Variables
variable "image_url" {
description = "Docker image URL"
type = string
default = "us-west1-docker.pkg.dev/compass-130ba/builds/api:latest"
}
variable "env" {
@@ -10,324 +10,97 @@ variable "env" {
default = "prod"
}
# 2. Local Constants
locals {
project = "compass-130ba"
region = "us-west1"
zone = "us-west1-b"
service_name = "api"
machine_type = "e2-small"
}
# 3. Provider & Backend
terraform {
backend "gcs" {
bucket = "compass-130ba-terraform-state"
prefix = "api"
prefix = "api-cloudrun" # Changed prefix so it doesn't collide with old state
}
}
provider "google" {
project = local.project
region = local.region
zone = local.zone
}
# Firebase Storage Buckets
# Note you still have to deploy the rules: `firebase deploy --only storage`
resource "google_storage_bucket" "public_storage" {
# /!\ That bucket is different from the one in firebase (compass-130ba.firebasestorage.app)
# as it errors when trying to do so:
# Error: googleapi: Error 403: Another user owns the domain compass-130ba.firebasestorage.app or a parent domain. You can either verify domain ownership at https://search.google.com/search-console/welcome?new_domain_name=compass-130ba.firebasestorage.app or find the current owner and ask that person to create the bucket for you, forbidden
# To be fixed later if they must be the same bucket (shared resources)
name = "compass-130ba"
location = "US"
force_destroy = false
# The Cloud Run Service
resource "google_cloud_run_v2_service" "api" {
name = local.service_name
location = local.region
ingress = "INGRESS_TRAFFIC_ALL"
uniform_bucket_level_access = true
template {
startup_cpu_boost = true
cors {
origin = ["*"]
method = ["GET", "HEAD", "PUT", "POST", "DELETE"]
response_header = ["*"]
max_age_seconds = 3600
}
}
scaling {
min_instance_count = 0 # This enables scaling to zero (saves money!)
max_instance_count = 10
}
containers {
image = var.image_url
# static IPs
resource "google_compute_global_address" "api_lb_ip" {
name = "api-lb-ip-2"
address_type = "EXTERNAL"
}
resources {
limits = {
cpu = "1" # 1 vCPU is standard, increase to "2" if heavy traffic
memory = "1Gi"
}
}
resource "google_compute_managed_ssl_certificate" "api_cert" {
name = "api-lb-cert-1"
ports {
container_port = 8080
}
managed {
domains = ["api.compassmeet.com"]
}
}
env {
name = "NEXT_PUBLIC_FIREBASE_ENV"
value = upper(var.env)
}
env {
name = "GOOGLE_CLOUD_PROJECT"
value = local.project
}
# Instance template with your Docker container
resource "google_compute_instance_template" "api_template" {
name_prefix = "${local.service_name}-"
machine_type = local.machine_type
tags = ["lb-health-check"]
disk {
source_image = "cos-cloud/cos-stable" # Container-Optimized OS
auto_delete = true
boot = true
}
network_interface {
network = "default"
subnetwork = "default"
access_config {
network_tier = "PREMIUM"
# Optional: CPU Boost speeds up cold starts significantly
startup_probe {
initial_delay_seconds = 0
timeout_seconds = 1
period_seconds = 3
failure_threshold = 3
tcp_socket {
port = 8080
}
}
}
}
service_account {
scopes = ["cloud-platform"]
}
metadata = {
gce-container-declaration = <<EOF
spec:
containers:
- image: '${var.image_url}'
env:
- name: NEXT_PUBLIC_FIREBASE_ENV
value: ${upper(var.env)}
- name: GOOGLE_CLOUD_PROJECT
value: ${local.project}
ports:
- containerPort: 80
EOF
google-logging-enabled = "true"
}
lifecycle {
create_before_destroy = true
}
}
# Managed instance group (for 1 VM)
resource "google_compute_region_instance_group_manager" "api_group" {
name = "${local.service_name}-group"
base_instance_name = "${local.service_name}-group"
region = local.region
target_size = 1
version {
instance_template = google_compute_instance_template.api_template.id
name = "primary"
}
update_policy {
type = "PROACTIVE"
minimal_action = "REPLACE"
max_unavailable_fixed = 0
max_surge_fixed = 3
}
named_port {
name = "http"
port = 80
}
auto_healing_policies {
health_check = google_compute_health_check.api_health_check.id
initial_delay_sec = 300
}
# Allow public (unauthenticated) access to the API
resource "google_cloud_run_v2_service_iam_member" "public_access" {
location = google_cloud_run_v2_service.api.location
name = google_cloud_run_v2_service.api.name
role = "roles/run.invoker"
member = "allUsers"
}
resource "google_compute_health_check" "api_health_check" {
name = "${local.service_name}-health-check"
check_interval_sec = 5
timeout_sec = 5
healthy_threshold = 2
unhealthy_threshold = 10
# Free Domain Mapping (Replaces the Load Balancer)
# Note: Check if your region supports 'google_cloud_run_domain_mapping'
# Otherwise, use 'google_cloud_run_v2_domain_mapping'
resource "google_cloud_run_domain_mapping" "api_domain" {
location = local.region
name = "api.compassmeet.com"
tcp_health_check {
port = "80"
}
}
# Backend service
resource "google_compute_backend_service" "api_backend" {
name = "${local.service_name}-backend"
protocol = "HTTP"
port_name = "http"
timeout_sec = 30
health_checks = [google_compute_health_check.api_health_check.id]
backend {
group = google_compute_region_instance_group_manager.api_group.instance_group
metadata {
namespace = local.project
}
log_config {
enable = true
spec {
route_name = google_cloud_run_v2_service.api.name
}
}
# URL map
resource "google_compute_url_map" "api_url_map" {
name = "${local.service_name}-url-map"
default_service = google_compute_backend_service.api_backend.self_link
host_rule {
hosts = ["*"]
path_matcher = "allpaths"
}
path_matcher {
name = "allpaths"
default_service = google_compute_backend_service.api_backend.self_link
#
# # Priority 0: passthrough /v0/* requests
# route_rules {
# priority = 1
# match_rules {
# prefix_match = "/v0"
# }
# service = google_compute_backend_service.api_backend.self_link
# }
#
# # Priority 1: rewrite everything else to /v0
# route_rules {
# priority = 2
# match_rules {
# prefix_match = "/"
# }
# route_action {
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
# path_prefix_rewrite = "/v0/"
# }
# }
# service = google_compute_backend_service.api_backend.self_link
# }
}
}
# HTTPS proxy
resource "google_compute_target_https_proxy" "api_https_proxy" {
name = "${local.service_name}-https-proxy"
url_map = google_compute_url_map.api_url_map.id
ssl_certificates = [google_compute_managed_ssl_certificate.api_cert.id]
}
# Global forwarding rule (load balancer frontend)
resource "google_compute_global_forwarding_rule" "api_https_forwarding_rule" {
name = "${local.service_name}-https-forwarding-rule-2"
target = google_compute_target_https_proxy.api_https_proxy.id
port_range = "443"
ip_address = google_compute_global_address.api_lb_ip.id
}
# HTTP-to-HTTPS redirect
resource "google_compute_url_map" "api_http_redirect" {
name = "${local.service_name}-http-redirect"
default_url_redirect {
https_redirect = true
redirect_response_code = "MOVED_PERMANENTLY_DEFAULT"
strip_query = false
}
}
resource "google_compute_target_http_proxy" "api_http_proxy" {
name = "${local.service_name}-http-proxy"
url_map = google_compute_url_map.api_http_redirect.id
}
resource "google_compute_global_forwarding_rule" "api_http_forwarding_rule" {
name = "${local.service_name}-http-forwarding-rule"
target = google_compute_target_http_proxy.api_http_proxy.id
port_range = "80"
ip_address = google_compute_global_address.api_lb_ip.id
}
# Firewalls
resource "google_compute_firewall" "allow_health_check" {
name = "allow-health-check-${local.service_name}"
network = "default"
allow {
protocol = "tcp"
ports = ["80"]
}
source_ranges = ["130.211.0.0/22", "35.191.0.0/16"]
target_tags = ["lb-health-check"]
}
resource "google_compute_firewall" "default_allow_https" {
name = "default-allow-http"
network = "default"
priority = 1000
direction = "INGRESS"
allow {
protocol = "tcp"
ports = ["80", "443"] # ["443", "8090-8099"]
}
source_ranges = ["0.0.0.0/0"]
}
# resource "google_compute_firewall" "default_allow_ssh" {
# name = "default-allow-ssh"
# network = "default"
# priority = 65534
# direction = "INGRESS"
#
# allow {
# protocol = "tcp"
# ports = ["22"]
# }
#
# source_ranges = ["0.0.0.0/0"]
# }
#
# resource "google_compute_firewall" "default_allow_internal" {
# name = "default-allow-internal"
# network = "default"
# priority = 65534
# direction = "INGRESS"
#
# allow {
# protocol = "tcp"
# ports = ["0-65535"]
# }
#
# allow {
# protocol = "udp"
# ports = ["0-65535"]
# }
#
# allow {
# protocol = "icmp"
# }
#
# source_ranges = ["10.128.0.0/9"]
# }
#
# # Allow ICMP (ping)
# resource "google_compute_firewall" "default_allow_icmp" {
# name = "default-allow-icmp"
# network = "default"
# priority = 65534
# direction = "INGRESS"
#
# allow {
# protocol = "icmp"
# }
#
# source_ranges = ["0.0.0.0/0"]
# }

View File

@@ -1,6 +1,6 @@
{
"name": "@compass/api",
"version": "1.37.1",
"version": "1.38.0",
"private": true,
"description": "Backend API endpoints",
"main": "src/serve.ts",