Skip to content

chore: deploy API to Cloud Run with Neon - #19

Open
Bebaz0 wants to merge 14 commits into
mainfrom
chore/deploy
Open

Bebaz0 wants to merge 14 commits into
mainfrom
chore/deploy

Conversation

@Bebaz0

@Bebaz0 Bebaz0 commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

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.sh creates the GCP side once, from Cloud Shell.

Why each non-obvious choice:

  • Triggered by workflow_run of PR checks, not by push. The merged result is checked before anything is built or migrated. Because workflow_run cannot 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 on main start in merge order, but GitHub replaces a pending run when a newer one queues, even with cancel-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 in pr.yml claimed every merge gets a full result, so it's corrected too. The job also requires event == 'push' from this repo, because branches: [main] would also match a fork PR whose branch is called main.
  • A docker job in pr.yml builds the image on every PR, without pushing. The Dockerfile only copies packages/config and apps/api. A PR that makes the API depend on a new package in packages/ would otherwise pass CI and break the deploy on main. It touches a file outside this PR's area, but it's the safety net for what this PR adds.
  • The actions in deploy-api.yml are pinned by commit SHA (checkout v7.0.1, auth v3.0.0, setup-gcloud v3.0.1). It's the only workflow with id-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, then update-traffic. Nest starts without touching the DB, so a revision with a bad DATABASE_URL would 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.
  • Migrations run as a Cloud Run Job, not on the runner. The job runs as api-runtime, the only identity with access to the database-url secret, so the connection string never passes through GitHub. The deployer has no direct access, but code it deploys runs with the secret. So whoever controls deploy-api.yml on main can reach the string, and the README says so.
  • src/db/migrate.ts uses drizzle-orm's migrator, not drizzle-kit. drizzle-kit is a dev dependency and stays out of the image. drizzle-kit migrate calls this same migrator, and both default to drizzle.__drizzle_migrations; I confirmed it in the installed source (drizzle-orm/pg-core/dialect.js) and tested it (below).
  • --execute-now --wait on the job. A failed migration fails the step. The service deploy never runs, and the previous revision keeps serving.
  • WIF condition on 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 on main cannot get credentials.
  • CORS_ORIGIN allows only acmfeup.eu and www, so Vercel previews can't call the production API. This is on purpose. A *.vercel.app suffix 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.
  • The setup script creates the service (placeholder image) and makes it public with --no-invoker-iam-check, not an allUsers binding. The acmfeup.eu organisation enforces Domain restricted sharing, which forbids allUsers. 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 enforces run.managed.requireInvokerIam, the script explains what to do.
  • IAM bindings are retried for up to a minute. A service account that was just created isn't visible to IAM for a few seconds; the first real run failed on exactly that.
  • --port=4000, not reading PORT. This needs no app code change.
  • The pnpm deploy --prod --legacy stage exists only to get a production-only node_modules. dist/ and drizzle/ are copied explicitly.
  • Scale to zero, max 2 instances, and a 10-image cleanup policy on Artifact Registry, to keep costs down.
  • No new env vars in code, so turbo.json and .env.example are unchanged. CORS_ORIGIN is set on the Cloud Run service.

How to test

Done locally, against a throwaway postgres:18-alpine on :5433:

  1. docker build -f apps/api/Dockerfile -t acmfeup-api . builds.
  2. Same control table, both directions:
    • drizzle-kit migrate, then node dist/db/migrate.js from the image: drizzle.__drizzle_migrations has 1 row and there is no "already exists" error.
    • Clean DB, the image migrator first, then drizzle-kit migrate: still 1 row. drizzle has only that one table.
  3. The migrator exits with 1 against an unreachable DB.
  4. docker run -p 4001:4000 -e DATABASE_URL=… acmfeup-api: /health returns 200 {"db":"up"}, and 503 once Postgres is stopped. The process runs as node with NODE_ENV=production.
  5. Image contents:
    • No .env* anywhere.
    • node_modules has only @nestjs, drizzle-orm, pg, reflect-metadata, rxjs and their dependencies. @types/node and @types/pg come in as optional peers of drizzle-orm; they are only type declarations.
    • drizzle/ is present.
  6. setup-gcp.sh passes bash -n and shellcheck. I ran it with a stubbed gcloud: it rejects a -pooler URL, 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.
  7. deploy-api.yml and pr.yml pass actionlint.
  8. docker stop on the image: exit 137 (SIGKILL) before enableShutdownHooks, exit 0 right away after it.
  9. pnpm lint && pnpm typecheck && pnpm test && pnpm build pass.

Verified in production: the script ran against the real acmfeup-platform project, and the API was made public with --no-invoker-iam-check.

Not verified yet (needs the first real deploy):

  • WIF token exchange;
  • the workflow run itself;
  • whether run.developer is enough for jobs deploy --execute-now, update-traffic, and deploying to a service with the invoker check turned off;
  • the workflow_run trigger and the job_workflow_ref value GitHub puts in the token;
  • TLS to Neon;
  • Vercel.

Checklist

  • I can explain every change in this PR during review, including any code an agent wrote
  • Tests cover the logic I added or changed. This is local verification, not Vitest: the migrator needs a real database.
  • Migrations are backwards compatible. N/A: no new migrations.
  • I did not edit a migration that is already merged, and new .sql files come from pnpm db:generate. N/A.
  • No secrets, tokens or real credentials anywhere in the diff

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 22, 2026 19:55
@vercel

vercel Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
new-website Ready Ready Preview Sep 23, 2026 5:39pm UTC

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
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

1 active deployment
Preview — db4829cd Deployed Sep 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants