From a90fdfb53aa16080c0b35d8c2296ecdf7eafcd65 Mon Sep 17 00:00:00 2001 From: Ethan Glenn Date: Mon, 15 Jun 2026 09:34:44 -0600 Subject: [PATCH] chore: add GCP Cloud Run deployment scripts Add a deploy/gcp toolkit (config-driven, idempotent bash scripts) plus a root cloudbuild.yaml to deploy the app to Google Cloud using Cloud Run, Cloud SQL for PostgreSQL, Artifact Registry and Secret Manager. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 ++ cloudbuild.yaml | 27 ++++++++ deploy/gcp/00-prerequisites.sh | 26 +++++++ deploy/gcp/01-database.sh | 55 +++++++++++++++ deploy/gcp/02-secrets.sh | 95 ++++++++++++++++++++++++++ deploy/gcp/03-build.sh | 16 +++++ deploy/gcp/04-deploy.sh | 90 +++++++++++++++++++++++++ deploy/gcp/05-migrate-job.sh | 40 +++++++++++ deploy/gcp/99-teardown.sh | 31 +++++++++ deploy/gcp/README.md | 120 +++++++++++++++++++++++++++++++++ deploy/gcp/common.sh | 86 +++++++++++++++++++++++ deploy/gcp/config.example.sh | 75 +++++++++++++++++++++ deploy/gcp/deploy-all.sh | 15 +++++ 13 files changed, 681 insertions(+) create mode 100644 cloudbuild.yaml create mode 100755 deploy/gcp/00-prerequisites.sh create mode 100755 deploy/gcp/01-database.sh create mode 100755 deploy/gcp/02-secrets.sh create mode 100755 deploy/gcp/03-build.sh create mode 100755 deploy/gcp/04-deploy.sh create mode 100755 deploy/gcp/05-migrate-job.sh create mode 100755 deploy/gcp/99-teardown.sh create mode 100644 deploy/gcp/README.md create mode 100755 deploy/gcp/common.sh create mode 100755 deploy/gcp/config.example.sh create mode 100755 deploy/gcp/deploy-all.sh diff --git a/.gitignore b/.gitignore index 6e1c5420ba..d886dc24af 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,8 @@ tmp/ # opencode .opencode/package-lock.json + +# gcp deployment (local config + generated certs/keys — never commit) +deploy/gcp/config.sh +deploy/gcp/*.p12 +deploy/gcp/*.pem diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 0000000000..5f6d81b886 --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,27 @@ +# Cloud Build config for building the Documenso container image. +# +# The Dockerfile lives at docker/Dockerfile (not the repo root), so `gcloud +# builds submit --tag ...` can't be used directly — this config points the +# build at the correct Dockerfile while keeping the repo root as the context. +# +# Usage (normally invoked by deploy/gcp/03-build.sh): +# gcloud builds submit --config=cloudbuild.yaml \ +# --substitutions=_IMAGE=REGION-docker.pkg.dev/PROJECT/REPO/documenso:latest . + +steps: + - name: gcr.io/cloud-builders/docker + args: + - build + - --file=docker/Dockerfile + - --tag=${_IMAGE} + - . + +images: + - ${_IMAGE} + +options: + # The monorepo build (turbo prune + install + build) is CPU heavy; use a + # larger machine so it doesn't time out on the default worker. + machineType: E2_HIGHCPU_8 + +timeout: 2400s diff --git a/deploy/gcp/00-prerequisites.sh b/deploy/gcp/00-prerequisites.sh new file mode 100755 index 0000000000..609a0596e9 --- /dev/null +++ b/deploy/gcp/00-prerequisites.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# +# Step 0 — Enable the required Google Cloud APIs and create the Artifact +# Registry repository that will hold the container image. + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +info "Enabling required Google Cloud APIs (this can take a minute)..." +gcloud_q services enable \ + run.googleapis.com \ + sqladmin.googleapis.com \ + artifactregistry.googleapis.com \ + cloudbuild.googleapis.com \ + secretmanager.googleapis.com + +info "Ensuring Artifact Registry repository '${REPO}' exists in ${REGION}..." +if gcloud_q artifacts repositories describe "$REPO" --location="$REGION" >/dev/null 2>&1; then + info "Repository already exists, skipping." +else + gcloud_q artifacts repositories create "$REPO" \ + --repository-format=docker \ + --location="$REGION" \ + --description="Documenso container images" +fi + +info "Prerequisites complete." diff --git a/deploy/gcp/01-database.sh b/deploy/gcp/01-database.sh new file mode 100755 index 0000000000..685e328d5f --- /dev/null +++ b/deploy/gcp/01-database.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Step 1 — Provision Cloud SQL for PostgreSQL: instance, database and user. +# The generated connection string (with password) is stored directly in +# Secret Manager as "-db-url" so the password is captured exactly once. + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +DB_URL_SECRET="${SECRET_PREFIX}-db-url" + +info "Ensuring Cloud SQL instance '${DB_INSTANCE}' exists (this can take several minutes)..." +if gcloud_q sql instances describe "$DB_INSTANCE" >/dev/null 2>&1; then + info "Instance already exists, skipping." +else + gcloud_q sql instances create "$DB_INSTANCE" \ + --database-version=POSTGRES_15 \ + --tier="$DB_TIER" \ + --region="$REGION" \ + --storage-auto-increase +fi + +info "Ensuring database '${DB_NAME}' exists..." +if ! gcloud_q sql databases describe "$DB_NAME" --instance="$DB_INSTANCE" >/dev/null 2>&1; then + gcloud_q sql databases create "$DB_NAME" --instance="$DB_INSTANCE" +fi + +CONNECTION_NAME="$(gcloud_q sql instances describe "$DB_INSTANCE" --format='value(connectionName)')" +info "Cloud SQL connection name: ${CONNECTION_NAME}" + +# Manage the user + password. We only (re)set the password when we also need to +# (re)write the connection-string secret, so the two never drift apart. +# Password uses only URL-safe alphanumerics to avoid any encoding pitfalls. +DB_PASSWORD="" +user_exists="$(gcloud_q sql users list --instance="$DB_INSTANCE" --format='value(name)' | grep -Fxc "$DB_USER" || true)" + +if [[ "$user_exists" == "0" ]]; then + info "Creating database user '${DB_USER}'..." + DB_PASSWORD="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9')" + gcloud_q sql users create "$DB_USER" --instance="$DB_INSTANCE" --password="$DB_PASSWORD" +elif ! secret_exists "$DB_URL_SECRET"; then + warn "DB user '${DB_USER}' exists but no '${DB_URL_SECRET}' secret was found — rotating its password to capture a fresh connection string." + DB_PASSWORD="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9')" + gcloud_q sql users set-password "$DB_USER" --instance="$DB_INSTANCE" --password="$DB_PASSWORD" +else + info "DB user and connection-string secret already exist — leaving the password untouched." +fi + +if [[ -n "$DB_PASSWORD" ]]; then + # Cloud Run connects over a unix socket at /cloudsql/. + DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@localhost/${DB_NAME}?host=/cloudsql/${CONNECTION_NAME}" + secret_put "$DB_URL_SECRET" "$DB_URL" + info "Stored connection string in secret '${DB_URL_SECRET}'." +fi + +info "Database setup complete." diff --git a/deploy/gcp/02-secrets.sh b/deploy/gcp/02-secrets.sh new file mode 100755 index 0000000000..d882491ed1 --- /dev/null +++ b/deploy/gcp/02-secrets.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Step 2 — Create application secrets in Secret Manager and grant the Cloud Run +# runtime service account the IAM it needs (read the secrets + connect to +# Cloud SQL). Safe to re-run: existing secret values are left untouched. + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +ENC_KEY_SECRET="${SECRET_PREFIX}-enc-key" +ENC_SECONDARY_SECRET="${SECRET_PREFIX}-enc-secondary" +NEXTAUTH_SECRET_NAME="${SECRET_PREFIX}-nextauth" +CERT_SECRET="${SECRET_PREFIX}-signing-cert" +PASSPHRASE_SECRET="${SECRET_PREFIX}-signing-passphrase" +SMTP_PASSWORD_SECRET="${SECRET_PREFIX}-smtp-password" +DB_URL_SECRET="${SECRET_PREFIX}-db-url" + +# Create a secret with a random 32-byte hex value only if it doesn't exist yet. +ensure_random_secret() { + local name="$1" + if secret_exists "$name"; then + info "Secret '${name}' already exists, leaving as-is." + else + secret_put "$name" "$(openssl rand -hex 32)" + info "Created secret '${name}'." + fi +} + +info "Creating encryption + auth secrets..." +ensure_random_secret "$ENC_KEY_SECRET" +ensure_random_secret "$ENC_SECONDARY_SECRET" +ensure_random_secret "$NEXTAUTH_SECRET_NAME" + +# ─── Signing certificate ───────────────────────────────────────────────────── + +if secret_exists "$CERT_SECRET"; then + info "Signing certificate secret '${CERT_SECRET}' already exists, leaving as-is." +else + info "Generating a self-signed signing certificate (CN=${SIGNING_CERT_CN})..." + TMP="$(mktemp -d)" + trap 'rm -rf "$TMP"' EXIT + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout "$TMP/key.pem" -out "$TMP/cert.pem" \ + -subj "/CN=${SIGNING_CERT_CN}" + openssl pkcs12 -export \ + -inkey "$TMP/key.pem" -in "$TMP/cert.pem" \ + -out "$TMP/cert.p12" -passout "pass:${SIGNING_PASSPHRASE:-}" + gcloud_q secrets create "$CERT_SECRET" --data-file="$TMP/cert.p12" >/dev/null + info "Stored self-signed certificate in secret '${CERT_SECRET}'." + warn "This is a SELF-SIGNED cert — fine for testing. Replace it for production (see README.md)." +fi + +# ─── Optional secrets ──────────────────────────────────────────────────────── + +if [[ -n "${SIGNING_PASSPHRASE:-}" ]]; then + secret_put "$PASSPHRASE_SECRET" "$SIGNING_PASSPHRASE" + info "Stored signing passphrase in secret '${PASSPHRASE_SECRET}'." +fi + +if [[ -n "${SMTP_PASSWORD:-}" ]]; then + secret_put "$SMTP_PASSWORD_SECRET" "$SMTP_PASSWORD" + info "Stored SMTP password in secret '${SMTP_PASSWORD_SECRET}'." +fi + +# ─── IAM ───────────────────────────────────────────────────────────────────── + +SA="$(runtime_sa)" +info "Granting secret access + Cloud SQL client to runtime service account: ${SA}" + +secrets_to_bind=( + "$ENC_KEY_SECRET" + "$ENC_SECONDARY_SECRET" + "$NEXTAUTH_SECRET_NAME" + "$CERT_SECRET" + "$DB_URL_SECRET" +) +[[ -n "${SIGNING_PASSPHRASE:-}" ]] && secrets_to_bind+=("$PASSPHRASE_SECRET") +[[ -n "${SMTP_PASSWORD:-}" ]] && secrets_to_bind+=("$SMTP_PASSWORD_SECRET") + +for s in "${secrets_to_bind[@]}"; do + if secret_exists "$s"; then + gcloud_q secrets add-iam-policy-binding "$s" \ + --member="serviceAccount:${SA}" \ + --role="roles/secretmanager.secretAccessor" >/dev/null + else + warn "Secret '${s}' not found — did you run 01-database.sh first? Skipping its IAM binding." + fi +done + +# Cloud Run needs this on the runtime SA to open the Cloud SQL socket. +gcloud_q projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA}" \ + --role="roles/cloudsql.client" \ + --condition=None >/dev/null + +info "Secrets and IAM complete." diff --git a/deploy/gcp/03-build.sh b/deploy/gcp/03-build.sh new file mode 100755 index 0000000000..b5cdb6f699 --- /dev/null +++ b/deploy/gcp/03-build.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# +# Step 3 — Build the container image with Cloud Build (using docker/Dockerfile) +# and push it to Artifact Registry. No local Docker required. + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +info "Submitting build to Cloud Build..." +info " Image: ${IMAGE}" +info " Context: ${REPO_ROOT}" + +gcloud_q builds submit "$REPO_ROOT" \ + --config="${REPO_ROOT}/cloudbuild.yaml" \ + --substitutions=_IMAGE="$IMAGE" + +info "Build complete and pushed to Artifact Registry." diff --git a/deploy/gcp/04-deploy.sh b/deploy/gcp/04-deploy.sh new file mode 100755 index 0000000000..b24b80b350 --- /dev/null +++ b/deploy/gcp/04-deploy.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Step 4 — Deploy (or update) the Cloud Run service: wire up the Cloud SQL +# connection, secrets and environment, then resolve the public URL. +# +# Note: the container runs `prisma migrate deploy` on startup (see +# docker/start.sh), so deploying a new image also applies pending migrations. + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +CONNECTION_NAME="$(gcloud_q sql instances describe "$DB_INSTANCE" --format='value(connectionName)')" + +# ─── Environment variables ─────────────────────────────────────────────────── +# Joined with '|' because some values (email addresses) contain commas/@; '|' +# never appears in our values, so it's a safe gcloud delimiter. + +env_kv=( + "NEXT_PUBLIC_UPLOAD_TRANSPORT=${UPLOAD_TRANSPORT}" + "NEXT_PRIVATE_SIGNING_TRANSPORT=local" + "NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12" +) + +if [[ -n "${SMTP_HOST:-}" ]]; then + env_kv+=( + "NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth" + "NEXT_PRIVATE_SMTP_HOST=${SMTP_HOST}" + "NEXT_PRIVATE_SMTP_PORT=${SMTP_PORT}" + "NEXT_PRIVATE_SMTP_USERNAME=${SMTP_USERNAME:-}" + "NEXT_PRIVATE_SMTP_FROM_NAME=${SMTP_FROM_NAME}" + "NEXT_PRIVATE_SMTP_FROM_ADDRESS=${SMTP_FROM_ADDRESS:-}" + ) +else + warn "SMTP_HOST is not set — deploying without email. Outbound email will fail until configured." +fi + +if [[ -n "${WEBAPP_URL:-}" ]]; then + env_kv+=("NEXT_PUBLIC_WEBAPP_URL=${WEBAPP_URL}") +fi + +env_str="$(IFS='|'; printf '%s' "${env_kv[*]}")" + +# ─── Secrets ───────────────────────────────────────────────────────────────── + +secret_kv=( + "NEXT_PRIVATE_ENCRYPTION_KEY=${SECRET_PREFIX}-enc-key:latest" + "NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=${SECRET_PREFIX}-enc-secondary:latest" + "NEXTAUTH_SECRET=${SECRET_PREFIX}-nextauth:latest" + "NEXT_PRIVATE_DATABASE_URL=${SECRET_PREFIX}-db-url:latest" + "NEXT_PRIVATE_DIRECT_DATABASE_URL=${SECRET_PREFIX}-db-url:latest" + # Mounted as a file at the path the app reads the signing cert from. + "/opt/documenso/cert.p12=${SECRET_PREFIX}-signing-cert:latest" +) +[[ -n "${SMTP_PASSWORD:-}" ]] && secret_kv+=("NEXT_PRIVATE_SMTP_PASSWORD=${SECRET_PREFIX}-smtp-password:latest") +[[ -n "${SIGNING_PASSPHRASE:-}" ]] && secret_kv+=("NEXT_PRIVATE_SIGNING_PASSPHRASE=${SECRET_PREFIX}-signing-passphrase:latest") + +secret_str="$(IFS=','; printf '%s' "${secret_kv[*]}")" + +# ─── Deploy ────────────────────────────────────────────────────────────────── + +info "Deploying service '${SERVICE}' to Cloud Run in ${REGION}..." +gcloud_q run deploy "$SERVICE" \ + --image="$IMAGE" \ + --region="$REGION" \ + --platform=managed \ + --allow-unauthenticated \ + --add-cloudsql-instances="$CONNECTION_NAME" \ + --service-account="$(runtime_sa)" \ + --memory="$RUN_MEMORY" \ + --cpu="$RUN_CPU" \ + --min-instances="$RUN_MIN_INSTANCES" \ + --max-instances="$RUN_MAX_INSTANCES" \ + --no-cpu-throttling \ + --concurrency="$RUN_CONCURRENCY" \ + --timeout=300 \ + --set-env-vars="^|^${env_str}" \ + --set-secrets="$secret_str" + +URL="$(gcloud_q run services describe "$SERVICE" --region="$REGION" --format='value(status.url)')" + +# If the user didn't pin a custom domain, wire the auto-assigned URL back in so +# absolute links / emails resolve correctly. +if [[ -z "${WEBAPP_URL:-}" ]]; then + info "Setting NEXT_PUBLIC_WEBAPP_URL=${URL} and redeploying a new revision..." + gcloud_q run services update "$SERVICE" --region="$REGION" \ + --update-env-vars="NEXT_PUBLIC_WEBAPP_URL=${URL}" +fi + +info "Deployed!" +info " Service URL: ${URL}" +info " Health check: ${URL}/api/health" diff --git a/deploy/gcp/05-migrate-job.sh b/deploy/gcp/05-migrate-job.sh new file mode 100755 index 0000000000..b8534f57b2 --- /dev/null +++ b/deploy/gcp/05-migrate-job.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Step 5 (OPTIONAL) — Run database migrations as a one-off Cloud Run Job. +# +# The service image already runs `prisma migrate deploy` on startup, so this is +# only useful if you want to harden the rollout: apply migrations once, here, +# before traffic shifts, rather than on every cold start. Uses the same image +# and the same Cloud SQL connection + DB secret as the service. + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +CONNECTION_NAME="$(gcloud_q sql instances describe "$DB_INSTANCE" --format='value(connectionName)')" +JOB="${SERVICE}-migrate" + +MIGRATE_CMD='cd /app/apps/remix && npx prisma migrate deploy --schema ../../packages/prisma/schema.prisma' + +common_flags=( + --image="$IMAGE" + --region="$REGION" + --set-cloudsql-instances="$CONNECTION_NAME" + --service-account="$(runtime_sa)" + --set-secrets="NEXT_PRIVATE_DATABASE_URL=${SECRET_PREFIX}-db-url:latest,NEXT_PRIVATE_DIRECT_DATABASE_URL=${SECRET_PREFIX}-db-url:latest" + --command=sh + --args="-c,${MIGRATE_CMD}" + --max-retries=1 + --task-timeout=600s +) + +if gcloud_q run jobs describe "$JOB" --region="$REGION" >/dev/null 2>&1; then + info "Updating migration job '${JOB}'..." + gcloud_q run jobs update "$JOB" "${common_flags[@]}" +else + info "Creating migration job '${JOB}'..." + gcloud_q run jobs create "$JOB" "${common_flags[@]}" +fi + +info "Executing migration job (streaming until complete)..." +gcloud_q run jobs execute "$JOB" --region="$REGION" --wait + +info "Migrations applied." diff --git a/deploy/gcp/99-teardown.sh b/deploy/gcp/99-teardown.sh new file mode 100755 index 0000000000..2342251623 --- /dev/null +++ b/deploy/gcp/99-teardown.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# DANGER — Tear down everything this toolkit created: the Cloud Run service, +# the optional migration job, the Cloud SQL instance (and ALL its data), the +# secrets, and the Artifact Registry repository. Requires typing the project +# id to confirm. + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +warn "This will PERMANENTLY DELETE the following in project '${PROJECT_ID}':" +echo " • Cloud Run service: ${SERVICE}" +echo " • Cloud Run job: ${SERVICE}-migrate (if present)" +echo " • Cloud SQL instance: ${DB_INSTANCE} (INCLUDING ALL DATA)" +echo " • Artifact Registry repo: ${REPO}" +echo " • Secrets: ${SECRET_PREFIX}-*" +echo +read -r -p "Type the project id (${PROJECT_ID}) to confirm: " confirm +[[ "$confirm" == "$PROJECT_ID" ]] || die "Confirmation did not match. Aborting." + +del() { info "Deleting: $*"; "$@" || warn " (already gone or failed, continuing)"; } + +del gcloud_q run services delete "$SERVICE" --region="$REGION" --quiet +del gcloud_q run jobs delete "${SERVICE}-migrate" --region="$REGION" --quiet +del gcloud_q sql instances delete "$DB_INSTANCE" --quiet +del gcloud_q artifacts repositories delete "$REPO" --location="$REGION" --quiet + +for s in enc-key enc-secondary nextauth db-url signing-cert signing-passphrase smtp-password; do + del gcloud_q secrets delete "${SECRET_PREFIX}-${s}" --quiet +done + +info "Teardown complete." diff --git a/deploy/gcp/README.md b/deploy/gcp/README.md new file mode 100644 index 0000000000..aa59b12603 --- /dev/null +++ b/deploy/gcp/README.md @@ -0,0 +1,120 @@ +# Deploying to Google Cloud (Cloud Run) + +This directory contains a small, idempotent toolkit for deploying this app to +Google Cloud using **Cloud Run + Cloud SQL (PostgreSQL) + Artifact Registry + +Secret Manager**. + +Cloud Run is the natural fit here: the app already ships a production +`docker/Dockerfile`, listens on `$PORT` (which Cloud Run injects), exposes a +`/api/health` endpoint, runs migrations on startup, and — with the default +`database` upload transport — stores documents in Postgres so the container +stays stateless. + +## Architecture + +``` + ┌──────────────────────┐ + users ──HTTPS──▶ │ Cloud Run service │ (autoscaling container) + │ documenso:latest │ + └──────────┬───────────┘ + │ unix socket /cloudsql/... + ┌──────────▼───────────┐ + │ Cloud SQL (PG 15) │ documents + app data + └──────────────────────┘ + Secret Manager ──▶ encryption keys, auth secret, DB URL, signing cert, SMTP pw + Artifact Registry ──▶ container image (built by Cloud Build) +``` + +## Prerequisites + +- The [`gcloud` CLI](https://cloud.google.com/sdk/docs/install) installed and + authenticated: `gcloud auth login` +- `openssl` (used to generate keys and the self-signed signing cert) +- A Google Cloud project with billing enabled, and permission to create the + resources above (Owner/Editor is simplest) + +## Quick start + +```bash +cd deploy/gcp +cp config.example.sh config.sh +$EDITOR config.sh # set PROJECT_ID, REGION, SMTP, ... +./deploy-all.sh +``` + +That runs every step in order. When it finishes it prints the service URL and +health-check URL. Each step is idempotent — re-running is safe. + +## What each script does + +| Script | Purpose | +|---|---| +| `config.example.sh` | Template you copy to `config.sh` (gitignored) and edit. | +| `common.sh` | Shared helpers + config loading. Sourced by the others; not run directly. | +| `00-prerequisites.sh` | Enables required APIs, creates the Artifact Registry repo. | +| `01-database.sh` | Creates the Cloud SQL instance, database and user; stores the connection string as a secret. | +| `02-secrets.sh` | Creates encryption/auth secrets + a self-signed signing cert; grants the runtime SA secret access and `cloudsql.client`. | +| `03-build.sh` | Builds the image via Cloud Build (`../../cloudbuild.yaml`) and pushes to Artifact Registry. | +| `04-deploy.sh` | Deploys the Cloud Run service with Cloud SQL, secrets and env wired in; resolves the public URL. | +| `05-migrate-job.sh` | **Optional.** Runs migrations as a one-off Cloud Run Job (see *Migrations* below). | +| `deploy-all.sh` | Runs steps 00→04 in order. | +| `99-teardown.sh` | **Destructive.** Deletes everything created here (asks for confirmation). | + +## Migrations + +The container runs `prisma migrate deploy` on startup (`docker/start.sh`), so a +normal deploy also applies pending migrations. Prisma guards this with an +advisory lock, so concurrent instances are safe. + +If you'd rather apply migrations explicitly *before* shifting traffic, run +`./05-migrate-job.sh` — it executes migrations once in a Cloud Run Job using the +same image. (The startup migration still runs and is a harmless no-op when +there's nothing pending.) + +## Custom domain (recommended for production) + +`NEXT_PUBLIC_WEBAPP_URL` must match the URL users hit. By default the deploy +script detects the auto-assigned `*.run.app` URL and wires it in. For a stable +URL, map your domain and set `WEBAPP_URL` in `config.sh` before deploying: + +```bash +gcloud run domain-mappings create \ + --service="$SERVICE" --domain="app.keepcontracts.com" --region="$REGION" +# add the DNS records it prints, then set WEBAPP_URL=https://app.keepcontracts.com +``` + +## Decision points & alternatives + +- **File storage** — defaults to `database` (documents in Postgres; keeps Cloud + Run stateless). For high volume, set `UPLOAD_TRANSPORT=s3` and point the + `NEXT_PRIVATE_UPLOAD_*` vars at a GCS bucket via its S3-compatible API. +- **Email** — you must supply real SMTP credentials in `config.sh` for outbound + email to work. Without `SMTP_HOST` the app boots but can't send mail. +- **Signing certificate** — the toolkit generates a **self-signed** `.p12` for + testing. For production, either replace the `*-signing-cert` secret with a + real certificate, or switch to **Cloud KMS HSM** signing + (`NEXT_PRIVATE_SIGNING_TRANSPORT=gcloud-hsm` + the `*_GCLOUD_HSM_*` env vars — + this app has first-class support for it). See + . +- **Background jobs** — defaults to the in-process `local` provider, which is + why `RUN_MIN_INSTANCES=1` and `--no-cpu-throttling` are set (a warm instance + with CPU always allocated is needed for scheduled work). For heavier setups, + switch to `bullmq` backed by Memorystore (Redis) or Inngest. + +## Troubleshooting + +- **Build can't push to Artifact Registry** — ensure the Cloud Build service + account has `roles/artifactregistry.writer` (default in most projects). +- **`--allow-unauthenticated` rejected** — an org policy may forbid public + services. Remove the flag and put Cloud Run behind IAP or a load balancer. +- **Service can't reach the DB** — confirm the runtime SA has + `roles/cloudsql.client` (granted by `02-secrets.sh`) and that the deploy used + `--add-cloudsql-instances`. +- **Apple Silicon, building locally instead of Cloud Build** — pass + `--platform linux/amd64` to `docker buildx`; Cloud Run runs amd64. + +## Teardown + +```bash +./99-teardown.sh # deletes the service, DB (and all data), secrets, repo +``` diff --git a/deploy/gcp/common.sh b/deploy/gcp/common.sh new file mode 100755 index 0000000000..cbba451b88 --- /dev/null +++ b/deploy/gcp/common.sh @@ -0,0 +1,86 @@ +# shellcheck shell=bash +# +# Shared helpers and config loading for the GCP deployment scripts. +# Sourced by every numbered script — not meant to be run directly. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +# ─── Load config ───────────────────────────────────────────────────────────── + +if [[ -f "${SCRIPT_DIR}/config.sh" ]]; then + # shellcheck source=/dev/null + source "${SCRIPT_DIR}/config.sh" +else + echo "ERROR: ${SCRIPT_DIR}/config.sh not found." >&2 + echo " Copy config.example.sh to config.sh and edit it first:" >&2 + echo " cp ${SCRIPT_DIR}/config.example.sh ${SCRIPT_DIR}/config.sh" >&2 + exit 1 +fi + +: "${PROJECT_ID:?Set PROJECT_ID in config.sh}" +: "${REGION:?Set REGION in config.sh}" + +# Defaults for anything the user may have trimmed from their config.sh. +: "${REPO:=documenso}" +: "${SERVICE:=documenso}" +: "${DB_INSTANCE:=documenso-db}" +: "${DB_NAME:=documenso}" +: "${DB_USER:=documenso}" +: "${DB_TIER:=db-custom-1-3840}" +: "${SECRET_PREFIX:=$SERVICE}" +: "${UPLOAD_TRANSPORT:=database}" +: "${RUN_MEMORY:=2Gi}" +: "${RUN_CPU:=2}" +: "${RUN_MIN_INSTANCES:=1}" +: "${RUN_MAX_INSTANCES:=10}" +: "${RUN_CONCURRENCY:=40}" +: "${SMTP_PORT:=587}" +: "${SMTP_FROM_NAME:=KeepContracts}" +: "${SIGNING_CERT_CN:=Documenso Self-Signed}" + +# Derived values +IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${SERVICE}:latest" + +# ─── Logging ───────────────────────────────────────────────────────────────── + +info() { printf '\033[1;34m▶ %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m⚠ %s\033[0m\n' "$*" >&2; } +die() { printf '\033[1;31m✖ %s\033[0m\n' "$*" >&2; exit 1; } + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"; } + +# Run gcloud always scoped to the configured project. +gcloud_q() { gcloud --project="${PROJECT_ID}" "$@"; } + +# secret_exists NAME +secret_exists() { gcloud_q secrets describe "$1" >/dev/null 2>&1; } + +# secret_put NAME VALUE — create the secret if missing, then add a new version. +secret_put() { + local name="$1" value="$2" + if secret_exists "$name"; then + printf '%s' "$value" | gcloud_q secrets versions add "$name" --data-file=- >/dev/null + else + printf '%s' "$value" | gcloud_q secrets create "$name" --data-file=- >/dev/null + fi +} + +# The runtime service account Cloud Run uses (default Compute Engine SA unless +# RUNTIME_SA is set in config.sh). +runtime_sa() { + if [[ -n "${RUNTIME_SA:-}" ]]; then + printf '%s' "$RUNTIME_SA" + else + local num + num="$(gcloud_q projects describe "$PROJECT_ID" --format='value(projectNumber)')" + printf '%s-compute@developer.gserviceaccount.com' "$num" + fi +} + +require_cmd gcloud +require_cmd openssl diff --git a/deploy/gcp/config.example.sh b/deploy/gcp/config.example.sh new file mode 100755 index 0000000000..05c414172c --- /dev/null +++ b/deploy/gcp/config.example.sh @@ -0,0 +1,75 @@ +# shellcheck shell=bash +# +# Copy this file to `config.sh` and edit the values below, then run the +# numbered scripts (or ./deploy-all.sh). `config.sh` is gitignored so your +# project-specific values and secrets never get committed. +# +# cp config.example.sh config.sh +# $EDITOR config.sh +# ./deploy-all.sh + +# ─── Required ──────────────────────────────────────────────────────────────── + +# Your Google Cloud project ID. +export PROJECT_ID="" + +# Region for Cloud Run, Cloud SQL and Artifact Registry. +export REGION="us-central1" + +# ─── Naming (sensible defaults — override only if you want) ────────────────── + +export REPO="documenso" # Artifact Registry repository +export SERVICE="documenso" # Cloud Run service name +export DB_INSTANCE="documenso-db" # Cloud SQL instance name +export DB_NAME="documenso" # Database name +export DB_USER="documenso" # Database user +export DB_TIER="db-custom-1-3840" # Cloud SQL machine type (1 vCPU / 3.75 GB) + +# Secrets are named "-enc-key", etc. Defaults to SERVICE. +# export SECRET_PREFIX="documenso" + +# Optional: deploy under a dedicated runtime service account instead of the +# default Compute Engine SA. Leave unset to use the default. +# export RUNTIME_SA="documenso-run@PROJECT_ID.iam.gserviceaccount.com" + +# ─── Public URL ────────────────────────────────────────────────────────────── + +# Leave blank to use the auto-assigned Cloud Run URL (the deploy script will +# detect it and wire it in automatically). Set this to your custom domain if +# you've mapped one (recommended for production) e.g. https://app.keepcontracts.com +export WEBAPP_URL="" + +# ─── Cloud Run sizing ──────────────────────────────────────────────────────── + +export RUN_MEMORY="2Gi" +export RUN_CPU="2" +# Keep at least 1 warm instance: the default in-process background-jobs provider +# ("local") only runs scheduled work while an instance is alive. +export RUN_MIN_INSTANCES="1" +export RUN_MAX_INSTANCES="10" +export RUN_CONCURRENCY="40" + +# ─── Storage ───────────────────────────────────────────────────────────────── + +# "database" stores uploaded documents in Postgres (simplest, keeps Cloud Run +# stateless). Use "s3" with a GCS bucket via its S3-compatible API for scale. +export UPLOAD_TRANSPORT="database" + +# ─── Email / SMTP (required to actually send email) ────────────────────────── + +# Leave SMTP_HOST blank to deploy without email wired up (the app will boot but +# outbound email will fail until you configure this and redeploy). +export SMTP_HOST="" +export SMTP_PORT="587" +export SMTP_USERNAME="" +export SMTP_PASSWORD="" # stored in Secret Manager, never committed +export SMTP_FROM_NAME="KeepContracts" +export SMTP_FROM_ADDRESS="noreply@keepcontracts.com" + +# ─── Document signing ──────────────────────────────────────────────────────── + +# A self-signed .p12 certificate is generated and stored in Secret Manager if +# one doesn't already exist. For production, replace the secret with a real +# cert or switch to Cloud KMS HSM signing (see README.md). +export SIGNING_PASSPHRASE="" +export SIGNING_CERT_CN="KeepContracts Self-Signed" diff --git a/deploy/gcp/deploy-all.sh b/deploy/gcp/deploy-all.sh new file mode 100755 index 0000000000..f52b8677de --- /dev/null +++ b/deploy/gcp/deploy-all.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# +# Run the full deployment in order: prerequisites → database → secrets → +# build → deploy. Each step is idempotent and safe to re-run. + +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +bash "${DIR}/00-prerequisites.sh" +bash "${DIR}/01-database.sh" +bash "${DIR}/02-secrets.sh" +bash "${DIR}/03-build.sh" +bash "${DIR}/04-deploy.sh" + +printf '\n\033[1;32m✓ Deployment finished.\033[0m\n'