Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
drizzle-kit is a dev dependency and stays out of the image, so production migrations use drizzle-orm's migrator. It is the same code drizzle-kit migrate runs and records history in the same drizzle.__drizzle_migrations table, so local and production never apply a migration twice.
Two stages: build with the full workspace, then ship only dist, the drizzle migrations and production node_modules from pnpm deploy --prod. The .dockerignore keeps .env files and the web app out of the context.
One script, run once in Cloud Shell, creates everything the deploy workflow needs: Artifact Registry, the database-url secret (read without echo), a runtime service account that can only read that secret, a deployer account for GitHub via Workload Identity Federation limited to main of this repo, and the public Cloud Run service. Re-running it keeps what exists.
Builds the image, pushes it to Artifact Registry, runs the migrations as a Cloud Run Job and waits for it, then deploys the service. A failed migration stops the workflow and the previous revision keeps serving. Authenticates with Workload Identity Federation, so there is no key and the database URL never reaches the runner.
Bebaz0
force-pushed
the
chore/deploy
branch
from
September 22, 2026 19:58
3bdff53 to
a6c8d8e
Compare
As PID 1 in the container, node ignores SIGTERM unless a handler exists, so every Cloud Run scale-down or new revision ended in a SIGKILL that cut off in-flight requests. enableShutdownHooks makes Nest close the server first.
- Trigger on workflow_run of PR checks succeeding on a push to main, so the merged result is checked before it is built and migrated. Every green merge now redeploys, since workflow_run cannot filter by path. - Deploy the revision with --no-traffic under a candidate tag, check /health on that URL, and only then move traffic. A revision that starts but cannot reach the database no longer takes over.
Pin the Workload Identity condition to job_workflow_ref, so another workflow running on main (a future pull_request_target one, or a third-party action inside it) cannot get deployer credentials. Also correct the comment claiming the deployer cannot read the secret: code it deploys runs with the secret, so it can reach it indirectly.
Describe the CI gate and the health check before traffic, correct what the secret setup does and does not protect, say setup must happen before the first deploying merge, and explain why Vercel previews cannot reach the production API.
A service account is not visible to IAM for a few seconds after it is created, so the first run of the setup script failed binding roles to github-deployer with "does not exist". Retry each binding for up to a minute.
The acmfeup.eu organisation enforces Domain restricted sharing, which forbids granting run.invoker to allUsers, so the setup script stopped at its last step. Turn off Cloud Run's invoker check on the service instead: same effect, scoped to this one service, and allowed by that policy. It was applied by hand in production and works.
The Dockerfile arrives with this PR and nothing validated it before a merge. It only copies packages/config and apps/api, so a PR that makes the API depend on a new package in packages/ would pass CI and break the deploy on main. Build the image, without pushing, in pr.yml.
- The image is not cached between deploys: each runner starts empty, so it is rebuilt from scratch every time. - With cancel-in-progress: false, GitHub still replaces a pending run when a newer one queues. Runs keep merge order, but a merge in between can end up with no CI run and no deploy of its own. Fixed in both deploy-api.yml and pr.yml, since the first relies on the second.
deploy-api.yml is the only workflow with id-token: write, so it is the one that can get GCP credentials. A tag can be moved to other code, a commit SHA cannot. The other workflows keep their tags for now.
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes
Deploys the API to Cloud Run (europe-west1) once CI passes on a merge to
main, with the database on Neon (Frankfurt). The workflow builds the image and runs the migrations as a Cloud Run Job. Then it deploys the new revision without traffic, checks its/health, and only then moves traffic to it.infra/setup-gcp.shcreates the GCP side once, from Cloud Shell.Why each non-obvious choice:
workflow_runof PR checks, not bypush. The merged result is checked before anything is built or migrated. Becauseworkflow_runcannot filter by path, every green merge redeploys: the image is rebuilt from scratch (each runner starts empty) and the migrations do nothing when there's nothing new. CI runs onmainstart in merge order, but GitHub replaces a pending run when a newer one queues, even withcancel-in-progress: false. So an intermediate merge can end up with no CI run and no deploy of its own; the next merge's deploy includes it. The comment inpr.ymlclaimed every merge gets a full result, so it's corrected too. The job also requiresevent == 'push'from this repo, becausebranches: [main]would also match a fork PR whose branch is calledmain.dockerjob inpr.ymlbuilds the image on every PR, without pushing. The Dockerfile only copiespackages/configandapps/api. A PR that makes the API depend on a new package inpackages/would otherwise pass CI and break the deploy onmain. It touches a file outside this PR's area, but it's the safety net for what this PR adds.deploy-api.ymlare pinned by commit SHA (checkoutv7.0.1,authv3.0.0,setup-gcloudv3.0.1). It's the only workflow withid-token: write, and a tag can be moved, a SHA cannot. The other workflows keep their tags for now.--no-traffic --tag=candidate, then/health, thenupdate-traffic. Nest starts without touching the DB, so a revision with a badDATABASE_URLwould pass Cloud Run's startup probe. Now it never takes traffic.app.enableShutdownHooks(). As PID 1, node ignored SIGTERM, so shutdowns ended in SIGKILL (exit 137 locally). With the hooks, it exits 0 straight away.api-runtime, the only identity with access to thedatabase-urlsecret, so the connection string never passes through GitHub. The deployer has no direct access, but code it deploys runs with the secret. So whoever controlsdeploy-api.ymlonmaincan reach the string, and the README says so.src/db/migrate.tsuses drizzle-orm's migrator, not drizzle-kit. drizzle-kit is a dev dependency and stays out of the image.drizzle-kit migratecalls this same migrator, and both default todrizzle.__drizzle_migrations; I confirmed it in the installed source (drizzle-orm/pg-core/dialect.js) and tested it (below).--execute-now --waiton the job. A failed migration fails the step. The service deploy never runs, and the previous revision keeps serving.repository_id+refs/heads/main+job_workflow_ref=deploy-api.yml@refs/heads/main. The numeric id is used instead of the name, because the name can be re-registered by someone else. PRs, forks, other branches and any other workflow onmaincannot get credentials.CORS_ORIGINallows onlyacmfeup.euandwww, so Vercel previews can't call the production API. This is on purpose. A*.vercel.appsuffix rule can be matched by anyone's project name, and preview frontends shouldn't run against the production API anyway. It's documented in the README.--no-invoker-iam-check, not anallUsersbinding. The acmfeup.eu organisation enforces Domain restricted sharing, which forbidsallUsers. This setting has the same effect, applies to this one service only, and is allowed by that policy. It lives in the script so the deployer never needs permission to change who can call the service. If the org also enforcesrun.managed.requireInvokerIam, the script explains what to do.--port=4000, not readingPORT. This needs no app code change.pnpm deploy --prod --legacystage exists only to get a production-onlynode_modules.dist/anddrizzle/are copied explicitly.turbo.jsonand.env.exampleare unchanged.CORS_ORIGINis set on the Cloud Run service.How to test
Done locally, against a throwaway
postgres:18-alpineon :5433:docker build -f apps/api/Dockerfile -t acmfeup-api .builds.drizzle-kit migrate, thennode dist/db/migrate.jsfrom the image:drizzle.__drizzle_migrationshas 1 row and there is no "already exists" error.drizzle-kit migrate: still 1 row.drizzlehas only that one table.docker run -p 4001:4000 -e DATABASE_URL=… acmfeup-api:/healthreturns 200{"db":"up"}, and 503 once Postgres is stopped. The process runs asnodewithNODE_ENV=production..env*anywhere.node_moduleshas only@nestjs,drizzle-orm,pg,reflect-metadata,rxjsand their dependencies.@types/nodeand@types/pgcome in as optional peers of drizzle-orm; they are only type declarations.drizzle/is present.setup-gcp.shpassesbash -nand shellcheck. I ran it with a stubbedgcloud: it rejects a-poolerURL, stores the direct one without echoing it, keeps the existing secret on Enter, and prints the org-policy message. It also retries IAM bindings, and skips making the service public when it already is.deploy-api.ymlandpr.ymlpass actionlint.docker stopon the image: exit 137 (SIGKILL) beforeenableShutdownHooks, exit 0 right away after it.pnpm lint && pnpm typecheck && pnpm test && pnpm buildpass.Verified in production: the script ran against the real
acmfeup-platformproject, and the API was made public with--no-invoker-iam-check.Not verified yet (needs the first real deploy):
run.developeris enough forjobs deploy --execute-now,update-traffic, and deploying to a service with the invoker check turned off;workflow_runtrigger and thejob_workflow_refvalue GitHub puts in the token;Checklist
.sqlfiles come frompnpm db:generate. N/A.🤖 Generated with Claude Code