Safe OnlineExam
Deploy the application

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

EnvironmentCloud Run serviceCloud SQL instanceRuntime service accountRegion
Developmentcanvas-seb-devcanvas-seb-devseb-canvas-devus-central1
Productioncanvas-seb-prodcanvas-seb-prodseb-canvas-produs-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.com

Confirm 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:

SecretSource file
dev_canvas_domaincanvas_domain
dev_lti_client_idlti_client_id
dev_lti_deployment_idlti_deployment_id
dev_tool_urltool_url
dev_lti_private_keylti_private_key
dev_session_secretsession_secret
dev_state_encryption_keystate_encryption_key
dev_api_client_idapi_client_id
dev_api_client_secretapi_client_secret
dev_seb_config_encryption_cert_pemseb-certs/seb-config-encryption.crt.pem
dev_database_passworddatabase_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"
done

Numbered 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=1

Confirm 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=1

Always 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" --wait

Production and additional installations

  • Production uses cloudbuild-prod.yaml, the canvas-seb-prod service, 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_ISSUER from the hostname — keep the default unless the launch iss differs). 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.

On this page