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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 27 additions & 0 deletions cloudbuild.yaml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions deploy/gcp/00-prerequisites.sh
Original file line number Diff line number Diff line change
@@ -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."
55 changes: 55 additions & 0 deletions deploy/gcp/01-database.sh
Original file line number Diff line number Diff line change
@@ -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 "<prefix>-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/<connection-name>.
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."
95 changes: 95 additions & 0 deletions deploy/gcp/02-secrets.sh
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +54 to +57

if [[ -n "${SMTP_PASSWORD:-}" ]]; then
secret_put "$SMTP_PASSWORD_SECRET" "$SMTP_PASSWORD"
info "Stored SMTP password in secret '${SMTP_PASSWORD_SECRET}'."
fi
Comment on lines +59 to +62

# ─── 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."
16 changes: 16 additions & 0 deletions deploy/gcp/03-build.sh
Original file line number Diff line number Diff line change
@@ -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."
90 changes: 90 additions & 0 deletions deploy/gcp/04-deploy.sh
Original file line number Diff line number Diff line change
@@ -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"
)
Comment on lines +13 to +21

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"
40 changes: 40 additions & 0 deletions deploy/gcp/05-migrate-job.sh
Original file line number Diff line number Diff line change
@@ -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."
31 changes: 31 additions & 0 deletions deploy/gcp/99-teardown.sh
Original file line number Diff line number Diff line change
@@ -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."
Loading