Google Cloud Run
Full first-time provisioning on Google Cloud Run with Cloud SQL — APIs, identity, database, secrets, the stable URL, and the two-pass deploy.
This is the recommended, managed path. Cloud Build acts as the release gate: it verifies the image, runs real PostgreSQL tests, publishes an immutable digest, runs migrations, and only then shifts traffic — without changing the service URL.
Conventions used here
The commands provision the development environment. For production, substitute
the production names (canvas-seb-prod, cloudbuild-prod.yaml, prod_* secrets)
noted at the end. Keep the shell variables from Step 1 for the whole session.
Maintained resources
| Environment | Cloud Run service | Cloud SQL instance | Runtime service account | Region |
|---|---|---|---|---|
| Development | canvas-seb-dev | canvas-seb-dev | seb-canvas-dev | us-central1 |
| Production | canvas-seb-prod | canvas-seb-prod | seb-canvas-prod | us-central1 |
Default database and user are both canvas_seb; the Artifact Registry repository
is canvas-seb-repo.
Select the project and enable APIs
Install the current Google Cloud CLI (or use Cloud Shell). The account needs permission to enable services and create IAM, Cloud SQL, Artifact Registry, Secret Manager, Cloud Run, Cloud Scheduler, and Cloud Build resources.
gcloud auth login
export PROJECT_ID="your-google-cloud-project-id"
export REGION="us-central1"
export SERVICE="canvas-seb-dev"
export SQL_INSTANCE="canvas-seb-dev"
export DATABASE_NAME="canvas_seb"
export DATABASE_USER="canvas_seb"
export REPOSITORY="canvas-seb-repo"
export IMAGE="canvas-seb-dev"
export RUNTIME_SA_NAME="seb-canvas-dev"
export RUNTIME_SA="${RUNTIME_SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
gcloud config set project "$PROJECT_ID"
gcloud config set run/region "$REGION"
gcloud services enable \
artifactregistry.googleapis.com \
cloudbuild.googleapis.com \
cloudscheduler.googleapis.com \
iam.googleapis.com \
run.googleapis.com \
secretmanager.googleapis.com \
sqladmin.googleapis.com \
serviceusage.googleapis.comConfirm billing is enabled before creating Cloud SQL.
Create Artifact Registry and runtime identity
gcloud artifacts repositories describe "$REPOSITORY" \
--location="$REGION" >/dev/null 2>&1 || \
gcloud artifacts repositories create "$REPOSITORY" \
--location="$REGION" \
--repository-format=docker \
--description="Safe Online Exam application images"
gcloud iam service-accounts describe "$RUNTIME_SA" >/dev/null 2>&1 || \
gcloud iam service-accounts create "$RUNTIME_SA_NAME" \
--display-name="Safe Online Exam development runtime"
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${RUNTIME_SA}" \
--role="roles/cloudsql.client"Least privilege
Grant Cloud SQL Client only. Secret access is granted per-secret in Step 6 — do not grant project-wide Secret Accessor.
Create Cloud SQL
This dev-sized example uses a zonal Enterprise instance. For production, choose a
reviewed capacity, --availability-type=regional, a backup location and retention
policy that meet institutional requirements, and deletion protection.
gcloud sql instances describe "$SQL_INSTANCE" >/dev/null 2>&1 || \
gcloud sql instances create "$SQL_INSTANCE" \
--database-version=POSTGRES_17 \
--edition=ENTERPRISE \
--cpu=1 \
--memory=3840MiB \
--region="$REGION" \
--availability-type=zonal \
--storage-type=SSD \
--storage-size=20 \
--storage-auto-increase \
--backup-start-time=06:00 \
--enable-point-in-time-recovery \
--retained-backups-count=7 \
--deletion-protection
gcloud sql databases describe "$DATABASE_NAME" \
--instance="$SQL_INSTANCE" >/dev/null 2>&1 || \
gcloud sql databases create "$DATABASE_NAME" \
--instance="$SQL_INSTANCE"Create a protected bootstrap directory and the database user. .local/ is
git-ignored, but it is not a long-term secret store.
umask 077
export BOOTSTRAP_DIR=".local/gcloud-${SERVICE}"
mkdir -p "$BOOTSTRAP_DIR"
openssl rand -base64 48 >"$BOOTSTRAP_DIR/database_password"
gcloud sql users describe "$DATABASE_USER" \
--instance="$SQL_INSTANCE" >/dev/null 2>&1 || \
gcloud sql users create "$DATABASE_USER" \
--instance="$SQL_INSTANCE" \
--password="$(tr -d '\r\n' <"$BOOTSTRAP_DIR/database_password")"Do not add broad Cloud SQL authorized networks. Cloud Run connects through the Cloud SQL attachment and authenticated Unix socket configured by the build.
Reserve the stable Cloud Run URL
For a new install, deploy a placeholder first so the final TOOL_URL is known
before Canvas and Secret Manager are configured. If the service already exists,
skip the placeholder and read its current URL.
if ! gcloud run services describe "$SERVICE" --region="$REGION" >/dev/null 2>&1; then
gcloud run deploy "$SERVICE" \
--image="us-docker.pkg.dev/cloudrun/container/hello" \
--region="$REGION" \
--platform=managed
fi
export TOOL_URL="$(gcloud run services describe "$SERVICE" \
--region="$REGION" \
--format='value(status.url)')"
printf 'Stable service URL: %s\n' "$TOOL_URL"Canvas browsers must reach the service without a Google sign-in challenge. Cloud
Build deliberately does not touch the public IAM policy, so grant invoker to
allUsers once:
gcloud run services add-iam-policy-binding "$SERVICE" \
--region="$REGION" \
--member="allUsers" \
--role="roles/run.invoker"Custom domain?
If you use a custom domain, finish and verify the mapping first and use that HTTPS
origin as TOOL_URL. If org policy forbids public Cloud Run invocation, place an
approved public HTTPS load balancer in front and use that stable origin — Canvas
cannot launch a service that requires Google authentication.
Create Canvas credentials and generate keys
Use the stable callback ${TOOL_URL}/api/oauth2callback to create the Canvas API
Developer Key (see API OAuth key). Record its values
and generate the signing key and SEB identity into protected files:
printf '%s' 'your-canvas-api-client-id' >"$BOOTSTRAP_DIR/api_client_id"
printf '%s' 'your-canvas-api-client-secret' >"$BOOTSTRAP_DIR/api_client_secret"
printf '%s' 'https://school.instructure.com' >"$BOOTSTRAP_DIR/canvas_domain"
printf '%s' "$TOOL_URL" >"$BOOTSTRAP_DIR/tool_url"
node scripts/generate-lti-private-key.mjs "$SERVICE" \
>"$BOOTSTRAP_DIR/lti_private_key"
openssl rand -base64 48 >"$BOOTSTRAP_DIR/seb_p12_password"
SEB_CERT_NAME=seb-config-encryption \
SEB_CERT_SUBJECT="/CN=Safe Online Exam Configuration Encryption/O=Organization" \
bash scripts/generate-seb-config-cert.sh \
"$BOOTSTRAP_DIR/seb-certs" \
"$BOOTSTRAP_DIR/seb_p12_password"
openssl rand -base64 48 >"$BOOTSTRAP_DIR/session_secret"
openssl rand -base64 48 >"$BOOTSTRAP_DIR/state_encryption_key"
printf '%s' 'bootstrap-pending' >"$BOOTSTRAP_DIR/lti_client_id"
printf '%s' 'bootstrap-pending' >"$BOOTSTRAP_DIR/lti_deployment_id"Move the private identity out immediately
Only the public certificate PEM is uploaded to the service. Move the .p12,
the matching private PEM, and the passphrase into your restricted device-vault
storage, then remove workstation copies. See
Certificate management.
Create and authorize Secret Manager secrets
The dev build expects these exact names, sourced from the bootstrap files:
| Secret | Source file |
|---|---|
dev_canvas_domain | canvas_domain |
dev_lti_client_id | lti_client_id |
dev_lti_deployment_id | lti_deployment_id |
dev_tool_url | tool_url |
dev_lti_private_key | lti_private_key |
dev_session_secret | session_secret |
dev_state_encryption_key | state_encryption_key |
dev_api_client_id | api_client_id |
dev_api_client_secret | api_client_secret |
dev_seb_config_encryption_cert_pem | seb-certs/seb-config-encryption.crt.pem |
dev_database_password | database_password |
Create the containers, add version 1, and grant only the runtime identity access:
for secret in \
dev_canvas_domain dev_lti_client_id dev_lti_deployment_id dev_tool_url \
dev_lti_private_key dev_session_secret dev_state_encryption_key \
dev_api_client_id dev_api_client_secret dev_seb_config_encryption_cert_pem \
dev_database_password; do
gcloud secrets describe "$secret" >/dev/null 2>&1 || \
gcloud secrets create "$secret" --replication-policy=automatic
done
gcloud secrets versions add dev_canvas_domain --data-file="$BOOTSTRAP_DIR/canvas_domain"
gcloud secrets versions add dev_lti_client_id --data-file="$BOOTSTRAP_DIR/lti_client_id"
gcloud secrets versions add dev_lti_deployment_id --data-file="$BOOTSTRAP_DIR/lti_deployment_id"
gcloud secrets versions add dev_tool_url --data-file="$BOOTSTRAP_DIR/tool_url"
gcloud secrets versions add dev_lti_private_key --data-file="$BOOTSTRAP_DIR/lti_private_key"
gcloud secrets versions add dev_session_secret --data-file="$BOOTSTRAP_DIR/session_secret"
gcloud secrets versions add dev_state_encryption_key --data-file="$BOOTSTRAP_DIR/state_encryption_key"
gcloud secrets versions add dev_api_client_id --data-file="$BOOTSTRAP_DIR/api_client_id"
gcloud secrets versions add dev_api_client_secret --data-file="$BOOTSTRAP_DIR/api_client_secret"
gcloud secrets versions add dev_seb_config_encryption_cert_pem \
--data-file="$BOOTSTRAP_DIR/seb-certs/seb-config-encryption.crt.pem"
gcloud secrets versions add dev_database_password --data-file="$BOOTSTRAP_DIR/database_password"
for secret in \
dev_canvas_domain dev_lti_client_id dev_lti_deployment_id dev_tool_url \
dev_lti_private_key dev_session_secret dev_state_encryption_key \
dev_api_client_id dev_api_client_secret dev_seb_config_encryption_cert_pem \
dev_database_password; do
gcloud secrets add-iam-policy-binding "$secret" \
--member="serviceAccount:${RUNTIME_SA}" \
--role="roles/secretmanager.secretAccessor"
doneNumbered versions only
Cloud Run secret environment variables are pinned to numbered versions. Never
change the build to use latest.
Grant the Cloud Build deployer permissions
The Cloud Build service account differs per project — discover it, don't assume.
export BUILD_SA="$(gcloud builds get-default-service-account \
--format='value(serviceAccountEmail)')"
printf 'Cloud Build service account: %s\n' "$BUILD_SA"
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${BUILD_SA}" \
--role="roles/run.admin"
gcloud artifacts repositories add-iam-policy-binding "$REPOSITORY" \
--location="$REGION" \
--member="serviceAccount:${BUILD_SA}" \
--role="roles/artifactregistry.writer"
gcloud iam service-accounts add-iam-policy-binding "$RUNTIME_SA" \
--member="serviceAccount:${BUILD_SA}" \
--role="roles/iam.serviceAccountUser"First application deployment (pass 1)
This replaces the placeholder with the real container, creates and runs the migration job, creates the cleanup job, then deploys the service. Override every dev secret version — the checked-in defaults describe the maintained environment, not a fresh install.
gcloud builds submit \
--config=cloudbuild-dev.yaml \
--substitutions=_CANVAS_DOMAIN_SECRET_VERSION=1,_LTI_CLIENT_ID_SECRET_VERSION=1,_LTI_DEPLOYMENT_ID_SECRET_VERSION=1,_LTI_DEPLOYMENT_ID_CHECKING_ENABLED=true,_TOOL_URL_SECRET_VERSION=1,_LTI_PRIVATE_KEY_SECRET_VERSION=1,_SESSION_SECRET_VERSION=1,_STATE_ENCRYPTION_KEY_SECRET_VERSION=1,_CANVAS_API_CLIENT_ID_SECRET_VERSION=1,_CANVAS_API_CLIENT_SECRET_VERSION=1,_SEB_CONFIG_ENCRYPTION_CERT_SECRET_VERSION=1,_DATABASE_PASSWORD_SECRET_VERSION=1Confirm the real application now owns the stable URL:
curl -fsS "${TOOL_URL}/health"
curl -fsS "${TOOL_URL}/ready"
curl -fsS "${TOOL_URL}/.well-known/jwks.json"
curl -fsS "${TOOL_URL}/lti/config"Finish Canvas setup and redeploy (pass 2)
Use ${TOOL_URL}/lti/config to create and enable the LTI Developer Key, install
the app, and record the deployment ID — the full Connect Canvas
section. Then add new immutable secret versions and redeploy with only those two
pins changed:
printf '%s' 'actual-canvas-lti-client-id' >"$BOOTSTRAP_DIR/lti_client_id"
printf '%s' 'actual-canvas-lti-deployment-id' >"$BOOTSTRAP_DIR/lti_deployment_id"
gcloud secrets versions add dev_lti_client_id --data-file="$BOOTSTRAP_DIR/lti_client_id"
gcloud secrets versions add dev_lti_deployment_id --data-file="$BOOTSTRAP_DIR/lti_deployment_id"
# If these are now version 2 (confirm with: gcloud secrets versions list SECRET_NAME):
gcloud builds submit \
--config=cloudbuild-dev.yaml \
--substitutions=_CANVAS_DOMAIN_SECRET_VERSION=1,_LTI_CLIENT_ID_SECRET_VERSION=2,_LTI_DEPLOYMENT_ID_SECRET_VERSION=2,_TOOL_URL_SECRET_VERSION=1,_LTI_PRIVATE_KEY_SECRET_VERSION=1,_SESSION_SECRET_VERSION=1,_STATE_ENCRYPTION_KEY_SECRET_VERSION=1,_CANVAS_API_CLIENT_ID_SECRET_VERSION=1,_CANVAS_API_CLIENT_SECRET_VERSION=1,_SEB_CONFIG_ENCRYPTION_CERT_SECRET_VERSION=1,_DATABASE_PASSWORD_SECRET_VERSION=1Always confirm actual version numbers with gcloud secrets versions list SECRET_NAME. Do not assume 2 if a secret already had versions.
Schedule cleanup
The build creates the cleanup job but not its schedule. Create a scheduler-only identity, grant it invoker on the job, and run it daily:
export SCHEDULER_SA_NAME="${SERVICE}-scheduler"
export SCHEDULER_SA="${SCHEDULER_SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
export CLEANUP_JOB="${SERVICE}-cleanup"
gcloud iam service-accounts describe "$SCHEDULER_SA" >/dev/null 2>&1 || \
gcloud iam service-accounts create "$SCHEDULER_SA_NAME" \
--display-name="Safe Online Exam cleanup scheduler"
gcloud run jobs add-iam-policy-binding "$CLEANUP_JOB" \
--region="$REGION" \
--member="serviceAccount:${SCHEDULER_SA}" \
--role="roles/run.invoker"
gcloud scheduler jobs describe "${SERVICE}-cleanup-daily" \
--location="$REGION" >/dev/null 2>&1 || \
gcloud scheduler jobs create http "${SERVICE}-cleanup-daily" \
--location="$REGION" \
--schedule="17 3 * * *" \
--time-zone="Etc/UTC" \
--uri="https://run.googleapis.com/v2/projects/${PROJECT_ID}/locations/${REGION}/jobs/${CLEANUP_JOB}:run" \
--http-method=POST \
--oauth-service-account-email="$SCHEDULER_SA"
gcloud run jobs execute "$CLEANUP_JOB" --region="$REGION" --waitProduction and additional installations
-
Production uses
cloudbuild-prod.yaml, thecanvas-seb-prodservice,APP_ENV=prod, disabled detector diagnostics, one minimum instance, and up to ten. Its non-database secrets share a single_SECRET_VERSION; create the same numbered version for every non-database secret before bumping it. Example:gcloud builds submit \ --config=cloudbuild-prod.yaml \ --substitutions=_SECRET_VERSION=1,_DATABASE_PASSWORD_SECRET_VERSION=1,_LTI_DEPLOYMENT_ID_CHECKING_ENABLED=true -
Another installation uses
cloudbuild-school.yaml, a parameterized template keyed by_SECRET_PREFIX. For a self-hosted Canvas, override its authorization and JWKS endpoints (but do not infer_LTI_ISSUERfrom the hostname — keep the default unless the launchissdiffers). See Self-hosted Canvas._LTI_KEY_SET_URL=https://canvas.example.edu/api/lti/security/jwks _LTI_AUTH_URL=https://canvas.example.edu/api/lti/authorize_redirect
Inspect every substitution and referenced secret before the first build.
Day-2 operations
Backups and restore drills, upgrades, application vs. schema rollback, pool sizing, and the dev database reset all live in Operations. Run the verification sequence before any real exam.
Next: Configuration reference · Certificate management · or jump to Connect Canvas.