Skip to content

Commit 27bb9b5

Browse files
committed
feat: harden Cloud Run security with Secret Manager, VPC connector, and private backend
- Secret Manager: move all sensitive env vars (Azure credentials, Gemini key, DB credentials) out of GitHub Secrets and into GCP Secret Manager; Cloud Run reads them at runtime via --set-secrets, so secrets are never exposed in workflow logs or build args. - VPC Connector: add Serverless VPC Access connector (terraform/network.tf) so Cloud Run services can reach Cloud SQL and each other over the private VPC network. - Private backend: set backend Cloud Run ingress to 'internal', blocking all public internet access. Frontend nginx now proxies /api/* to the backend's internal URL (with BACKEND_URL injected as a runtime env var), so the browser never needs a direct connection to the backend. - Terraform IaC: terraform/ directory manages the VPC connector, Secret Manager secrets, Cloud Run service account, and Cloud SQL (importable via import.sh). CI continues to own image builds and Cloud Run deployments. - Data migration script: scripts/migrate_db.sh migrates PostgreSQL data between Cloud SQL instances via Cloud SQL Auth Proxy if the database ever needs to be rebuilt. https://claude.ai/code/session_01SRRzCWrpwgMpdYFurMVn7m
1 parent a9a4e0f commit 27bb9b5

15 files changed

Lines changed: 500 additions & 40 deletions
Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
# This workflow builds and pushes Docker containers to Google Artifact Registry
2-
# and deploys both backend and frontend on Cloud Run when a commit is pushed to the "production"
3-
# branch.
1+
# Build and deploy QueryPal to Cloud Run.
2+
# Runs on pushes to the production branch.
3+
#
4+
# Infrastructure changes (VPC connector, Secret Manager, IAM) are managed by
5+
# Terraform in the terraform/ directory and must be applied before first deploy.
46

57
name: 'Build and Deploy QueryPal to Cloud Run'
68

@@ -16,6 +18,10 @@ env:
1618
BACKEND_SERVICE: 'querypal-backend'
1719
FRONTEND_SERVICE: 'querypal-frontend'
1820
WORKLOAD_IDENTITY_PROVIDER: 'projects/874216619692/locations/global/workloadIdentityPools/github/providers/querypal'
21+
# Cloud Run service account created by Terraform (terraform/iam.tf).
22+
CLOUD_RUN_SA: 'querypal-cloudrun-sa@gen-lang-client-0698668474.iam.gserviceaccount.com'
23+
# VPC connector created by Terraform (terraform/network.tf).
24+
VPC_CONNECTOR: 'querypal-vpc-connector'
1925

2026
jobs:
2127
deploy:
@@ -29,80 +35,93 @@ jobs:
2935
- name: 'Checkout'
3036
uses: 'actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332' # actions/checkout@v4
3137

32-
# Configure Workload Identity Federation and generate an access token.
3338
- id: 'auth'
3439
name: 'Authenticate to Google Cloud'
3540
uses: 'google-github-actions/auth@f112390a2df9932162083945e46d439060d66ec2' # google-github-actions/auth@v2
3641
with:
3742
workload_identity_provider: '${{ env.WORKLOAD_IDENTITY_PROVIDER }}'
38-
service_account: 'github-actions@gen-lang-client-0698668474.iam.gserviceaccount.com'
43+
service_account: 'github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com'
3944

40-
# Set up Cloud SDK
4145
- name: 'Set up Cloud SDK'
4246
uses: 'google-github-actions/setup-gcloud@98ddc00a17442e89a24bbf282954a3b65ce6d200' # google-github-actions/setup-gcloud@v2
4347

44-
# Configure Docker to use gcloud as a credential helper
4548
- name: 'Configure Docker for GCR'
46-
run: |-
47-
gcloud auth configure-docker --quiet
49+
run: gcloud auth configure-docker --quiet
50+
51+
# ── Backend ──────────────────────────────────────────────────────────────
4852

49-
# Build and Push Backend Container
5053
- name: 'Build and Push Backend Container'
5154
run: |-
5255
cd backend
5356
DOCKER_TAG="gcr.io/${{ env.PROJECT_ID }}/${{ env.BACKEND_SERVICE }}:${{ github.sha }}"
5457
docker build --tag "${DOCKER_TAG}" --platform linux/amd64 .
5558
docker push "${DOCKER_TAG}"
5659
57-
# Deploy Backend to Cloud Run
5860
- id: 'deploy-backend'
5961
name: 'Deploy Backend to Cloud Run'
6062
uses: 'google-github-actions/deploy-cloudrun@33553064113a37d688aa6937bacbdc481580be17' # google-github-actions/deploy-cloudrun@v2
6163
with:
6264
service: '${{ env.BACKEND_SERVICE }}'
6365
region: '${{ env.REGION }}'
6466
image: 'gcr.io/${{ env.PROJECT_ID }}/${{ env.BACKEND_SERVICE }}:${{ github.sha }}'
67+
# Non-secret runtime configuration only.
6568
env_vars: |
6669
ENVIRONMENT=production
67-
AZURE_TENANT_ID=${{ secrets.AZURE_TENANT_ID }}
68-
AZURE_CLIENT_ID=${{ secrets.AZURE_CLIENT_ID }}
69-
AZURE_CLIENT_SECRET=${{ secrets.AZURE_CLIENT_SECRET }}
7070
ARM_SCOPE=https://management.azure.com/.default
71-
GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}
72-
DB_USER=${{ secrets.DB_USER }}
73-
DB_PASS=${{ secrets.DB_PASS }}
7471
DB_NAME=querypal
75-
DB_UNIX_SOCKET=/cloudsql/gen-lang-client-0698668474:europe-west1:querypal-db
72+
DB_UNIX_SOCKET=/cloudsql/${{ env.PROJECT_ID }}:${{ env.REGION }}:querypal-db
73+
# Sensitive values are read directly from Secret Manager at runtime.
74+
# Secret must exist before first deploy (created by terraform/secrets.tf).
75+
secrets: |
76+
AZURE_TENANT_ID=querypal-azure-tenant-id:latest
77+
AZURE_CLIENT_ID=querypal-azure-client-id:latest
78+
AZURE_CLIENT_SECRET=querypal-azure-client-secret:latest
79+
GEMINI_API_KEY=querypal-gemini-api-key:latest
80+
DB_USER=querypal-db-user:latest
81+
DB_PASS=querypal-db-pass:latest
7682
flags: |
7783
--port=8000
78-
--add-cloudsql-instances=gen-lang-client-0698668474:europe-west1:querypal-db
84+
--service-account=${{ env.CLOUD_RUN_SA }}
85+
--add-cloudsql-instances=${{ env.PROJECT_ID }}:${{ env.REGION }}:querypal-db
86+
--vpc-connector=${{ env.VPC_CONNECTOR }}
87+
--vpc-egress=private-ranges-only
88+
--ingress=internal
7989
--allow-unauthenticated
8090
81-
# Build and Push Frontend Container
91+
# ── Frontend ─────────────────────────────────────────────────────────────
92+
8293
- name: 'Build and Push Frontend Container'
8394
run: |-
8495
cd frontend
8596
DOCKER_TAG="gcr.io/${{ env.PROJECT_ID }}/${{ env.FRONTEND_SERVICE }}:${{ github.sha }}"
8697
docker build --tag "${DOCKER_TAG}" --platform linux/amd64 \
87-
--build-arg VITE_API_BASE_URL=${{ steps.deploy-backend.outputs.url }} \
98+
--build-arg VITE_API_BASE_URL=/api \
8899
--build-arg VITE_AZURE_REDIRECT_URI=https://querypal.virtonomy.io \
89100
.
90101
docker push "${DOCKER_TAG}"
91102
92-
# Deploy Frontend to Cloud Run
93103
- id: 'deploy-frontend'
94104
name: 'Deploy Frontend to Cloud Run'
95105
uses: 'google-github-actions/deploy-cloudrun@33553064113a37d688aa6937bacbdc481580be17' # google-github-actions/deploy-cloudrun@v2
96106
with:
97107
service: '${{ env.FRONTEND_SERVICE }}'
98108
region: '${{ env.REGION }}'
99109
image: 'gcr.io/${{ env.PROJECT_ID }}/${{ env.FRONTEND_SERVICE }}:${{ github.sha }}'
110+
# BACKEND_URL is the internal Cloud Run URL; nginx uses it at runtime to
111+
# proxy /api/* requests to the backend (which is not publicly reachable).
112+
env_vars: |
113+
BACKEND_URL=${{ steps.deploy-backend.outputs.url }}
100114
flags: |
101115
--port=4000
116+
--service-account=${{ env.CLOUD_RUN_SA }}
117+
--vpc-connector=${{ env.VPC_CONNECTOR }}
118+
--vpc-egress=all-traffic
119+
--ingress=all
102120
--allow-unauthenticated
103121
104-
# Show output URLs
122+
# ── Summary ───────────────────────────────────────────────────────────────
123+
105124
- name: 'Show deployment URLs'
106125
run: |-
107-
echo "Backend URL: ${{ steps.deploy-backend.outputs.url }}"
108126
echo "Frontend URL: ${{ steps.deploy-frontend.outputs.url }}"
127+
echo "Backend URL: ${{ steps.deploy-backend.outputs.url }} (internal only)"

frontend/Dockerfile

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,7 @@ COPY --from=build /app/dist .
2121
EXPOSE 4000
2222
RUN rm -rf /etc/nginx/conf.d/default.conf
2323
COPY nginx.conf /etc/nginx/conf.d/default.conf.template
24-
25-
# Create script to substitute environment variables in nginx config
26-
RUN echo '#!/bin/sh' > /docker-entrypoint.sh && \
27-
echo '# Set PORT default if not provided' >> /docker-entrypoint.sh && \
28-
echo 'export PORT=${PORT:-4000}' >> /docker-entrypoint.sh && \
29-
echo 'envsubst "\$PORT" < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf' >> /docker-entrypoint.sh && \
30-
echo 'exec nginx -g "daemon off;"' >> /docker-entrypoint.sh && \
31-
chmod +x /docker-entrypoint.sh
24+
COPY docker-entrypoint.sh /docker-entrypoint.sh
25+
RUN chmod +x /docker-entrypoint.sh
3226

3327
CMD ["/docker-entrypoint.sh"]

frontend/docker-entrypoint.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/sh
2+
set -e
3+
4+
export PORT=${PORT:-4000}
5+
# BACKEND_URL is the internal Cloud Run URL of the backend service.
6+
# In production this is set as a Cloud Run environment variable.
7+
# Locally, point directly at the backend container.
8+
export BACKEND_URL=${BACKEND_URL:-http://localhost:8000}
9+
10+
# Substitute only $PORT and $BACKEND_URL; leave nginx's own $variables untouched.
11+
envsubst '$PORT $BACKEND_URL' \
12+
< /etc/nginx/conf.d/default.conf.template \
13+
> /etc/nginx/conf.d/default.conf
14+
15+
exec nginx -g "daemon off;"

frontend/nginx.conf

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,26 @@ server {
22
listen $PORT;
33
server_name localhost;
44

5+
# Serve the React SPA static files.
56
location / {
67
root /usr/share/nginx/html;
78
index index.html index.htm;
89
try_files $uri $uri/ /index.html;
910
}
1011

11-
# Optionally, proxy API requests to backend if needed
12-
# location /api/ {
13-
# proxy_pass http://backend:8000;
14-
# proxy_set_header Host $host;
15-
# proxy_set_header X-Real-IP $remote_addr;
16-
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
17-
# proxy_set_header X-Forwarded-Proto $scheme;
18-
# }
12+
# Proxy all /api/ requests to the internal backend Cloud Run service.
13+
# The trailing slash on proxy_pass strips the /api prefix before forwarding,
14+
# so /api/query/execute becomes /query/execute on the backend.
15+
# BACKEND_URL is injected at container startup via docker-entrypoint.sh.
16+
location /api/ {
17+
proxy_pass $BACKEND_URL/;
18+
proxy_http_version 1.1;
19+
proxy_set_header Host $proxy_host;
20+
proxy_set_header X-Real-IP $remote_addr;
21+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
22+
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
23+
proxy_read_timeout 300s;
24+
proxy_connect_timeout 10s;
25+
proxy_send_timeout 300s;
26+
}
1927
}

scripts/migrate_db.sh

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env bash
2+
# Migrate PostgreSQL data between Cloud SQL instances using Cloud SQL Auth Proxy.
3+
#
4+
# Use this when you need to move data to a new Cloud SQL instance
5+
# (e.g., after recreating the instance via Terraform).
6+
#
7+
# Prerequisites:
8+
# - gcloud CLI authenticated with Cloud SQL Admin permissions
9+
# - cloud-sql-proxy binary in PATH (https://cloud.google.com/sql/docs/postgres/sql-proxy)
10+
# - pg_dump / psql installed locally
11+
#
12+
# Usage:
13+
# ./scripts/migrate_db.sh \
14+
# --source-instance gen-lang-client-0698668474:europe-west1:querypal-db \
15+
# --target-instance gen-lang-client-0698668474:europe-west1:querypal-db-new \
16+
# --db-name querypal \
17+
# --db-user postgres
18+
#
19+
# The script will prompt for the database password interactively.
20+
21+
set -euo pipefail
22+
23+
# ── Argument parsing ─────────────────────────────────────────────────────────
24+
25+
SOURCE_INSTANCE=""
26+
TARGET_INSTANCE=""
27+
DB_NAME="querypal"
28+
DB_USER="postgres"
29+
30+
while [[ $# -gt 0 ]]; do
31+
case "$1" in
32+
--source-instance) SOURCE_INSTANCE="$2"; shift 2 ;;
33+
--target-instance) TARGET_INSTANCE="$2"; shift 2 ;;
34+
--db-name) DB_NAME="$2"; shift 2 ;;
35+
--db-user) DB_USER="$2"; shift 2 ;;
36+
*) echo "Unknown argument: $1" >&2; exit 1 ;;
37+
esac
38+
done
39+
40+
if [[ -z "$SOURCE_INSTANCE" || -z "$TARGET_INSTANCE" ]]; then
41+
echo "Usage: $0 --source-instance CONN_NAME --target-instance CONN_NAME [--db-name NAME] [--db-user USER]" >&2
42+
exit 1
43+
fi
44+
45+
# ── Setup ────────────────────────────────────────────────────────────────────
46+
47+
SOURCE_PORT=5432
48+
TARGET_PORT=5433
49+
DUMP_FILE="$(mktemp /tmp/querypal_dump_XXXXXX.sql)"
50+
SOURCE_PROXY_PID=""
51+
TARGET_PROXY_PID=""
52+
53+
cleanup() {
54+
echo "==> Cleaning up..."
55+
[[ -n "$SOURCE_PROXY_PID" ]] && kill "$SOURCE_PROXY_PID" 2>/dev/null || true
56+
[[ -n "$TARGET_PROXY_PID" ]] && kill "$TARGET_PROXY_PID" 2>/dev/null || true
57+
rm -f "$DUMP_FILE"
58+
}
59+
trap cleanup EXIT
60+
61+
echo "==> Migration plan:"
62+
echo " Source: ${SOURCE_INSTANCE} (port ${SOURCE_PORT})"
63+
echo " Target: ${TARGET_INSTANCE} (port ${TARGET_PORT})"
64+
echo " Database: ${DB_NAME}"
65+
echo ""
66+
read -rsp "Enter database password for '${DB_USER}': " DB_PASS
67+
echo ""
68+
export PGPASSWORD="$DB_PASS"
69+
70+
# ── Start Cloud SQL Auth Proxy ───────────────────────────────────────────────
71+
72+
echo "==> Starting Cloud SQL Auth Proxy for source instance..."
73+
cloud-sql-proxy \
74+
"${SOURCE_INSTANCE}?port=${SOURCE_PORT}" \
75+
--quiet &
76+
SOURCE_PROXY_PID=$!
77+
78+
echo "==> Starting Cloud SQL Auth Proxy for target instance..."
79+
cloud-sql-proxy \
80+
"${TARGET_INSTANCE}?port=${TARGET_PORT}" \
81+
--quiet &
82+
TARGET_PROXY_PID=$!
83+
84+
# Wait for proxies to be ready.
85+
sleep 3
86+
87+
# ── Dump source ──────────────────────────────────────────────────────────────
88+
89+
echo "==> Dumping source database to ${DUMP_FILE}..."
90+
pg_dump \
91+
--host=127.0.0.1 \
92+
--port="${SOURCE_PORT}" \
93+
--username="${DB_USER}" \
94+
--dbname="${DB_NAME}" \
95+
--format=plain \
96+
--no-owner \
97+
--no-acl \
98+
--file="${DUMP_FILE}"
99+
100+
DUMP_SIZE=$(du -sh "$DUMP_FILE" | cut -f1)
101+
echo " Dump complete: ${DUMP_SIZE}"
102+
103+
# ── Restore to target ────────────────────────────────────────────────────────
104+
105+
echo "==> Restoring to target database..."
106+
# Drop and recreate schema to ensure a clean slate.
107+
psql \
108+
--host=127.0.0.1 \
109+
--port="${TARGET_PORT}" \
110+
--username="${DB_USER}" \
111+
--dbname=postgres \
112+
--command="DROP DATABASE IF EXISTS ${DB_NAME};"
113+
114+
psql \
115+
--host=127.0.0.1 \
116+
--port="${TARGET_PORT}" \
117+
--username="${DB_USER}" \
118+
--dbname=postgres \
119+
--command="CREATE DATABASE ${DB_NAME};"
120+
121+
psql \
122+
--host=127.0.0.1 \
123+
--port="${TARGET_PORT}" \
124+
--username="${DB_USER}" \
125+
--dbname="${DB_NAME}" \
126+
--file="${DUMP_FILE}"
127+
128+
echo ""
129+
echo "==> Migration complete."
130+
echo " Verify the target database before updating DB_UNIX_SOCKET in Cloud Run."

terraform/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.terraform/
2+
.terraform.lock.hcl
3+
terraform.tfstate
4+
terraform.tfstate.backup
5+
*.tfplan
6+
tfplan
7+
terraform.tfvars
8+
*.auto.tfvars

terraform/database.tf

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Existing Cloud SQL instance brought under Terraform management.
2+
# Run ./import.sh once to import the existing instance into Terraform state
3+
# before applying this configuration.
4+
resource "google_sql_database_instance" "querypal_db" {
5+
name = var.cloud_sql_instance_name
6+
database_version = "POSTGRES_15"
7+
region = var.region
8+
9+
settings {
10+
tier = "db-f1-micro"
11+
12+
backup_configuration {
13+
enabled = true
14+
start_time = "02:00"
15+
point_in_time_recovery_enabled = true
16+
transaction_log_retention_days = 7
17+
}
18+
19+
ip_configuration {
20+
ipv4_enabled = true
21+
# Require SSL for all connections.
22+
ssl_mode = "ENCRYPTED_ONLY"
23+
}
24+
25+
database_flags {
26+
name = "log_connections"
27+
value = "on"
28+
}
29+
}
30+
31+
# Prevent accidental destruction of the production database.
32+
deletion_protection = true
33+
}
34+
35+
resource "google_sql_database" "querypal" {
36+
name = var.db_name
37+
instance = google_sql_database_instance.querypal_db.name
38+
}

0 commit comments

Comments
 (0)