Skip to content

Deployment Demo 4

GabrielaBerimbau edited this page Sep 28, 2026 · 2 revisions

Mealchemy Deployment Documentation

Deployment section for the SAS. Covers the deployment components, the live system, environments, containerisation, secrets, security, rollback, the required diagrams, guardrails, catches noted and planned for, and alternatives that were considered.


1. Deployment components

Every managed service in Mealchemy's deployment and what each is used for.

Component Provider Used for
Cloud Run Google Cloud Runs the backend and engine container services in europe-west3. Scales to zero when idle.
Cloud Storage Google Cloud Stores recipe photos in separate staging and production buckets in europe-west3. Flutter uploads directly with short-lived signed URLs.
Artifact Registry Google Cloud Stores backend and engine Docker images. Each image is tagged with the git commit hash that produced it.
Secret Manager Google Cloud Stores database credentials, database URLs (pooled and non-pooled), and the JWT secret. Injected into Cloud Run when a container starts.
Workload Identity Federation Google Cloud Lets GitHub Actions authenticate to GCP with a short-lived token instead of a downloaded key.
IAM Google Cloud Grants specific permissions to three separate service accounts, one per job.
Cloud Build service accounts Google Cloud Three accounts: one that deploys, one that runs the backend, one that runs the engine. Each has only what it needs in terms of credentials.
Neon Neon (managed Postgres) Hosts staging and production databases as two branches of one project in eu-central-1. Provides both pooled and non-pooled connection endpoints.
Firebase Hosting Firebase Serves the static landing page.
Firebase App Distribution Firebase Distributes signed Android APKs to testers.
GitHub Pages GitHub Serves the brand style guide from the design-spec folder.
GitHub Actions GitHub Runs every workflow: CI, CD, rollback, wiki sync, landing page deploy, design spec deploy, and Android delivery.
Flyway Open source, run in CD Applies database migrations to Neon before each deploy. Uses the non-pooled Neon endpoint because it holds an advisory lock during migration.

2. Live, accessible system

Every deployed URL and how it was verified live.

Service URL Verified by
Backend (production) https://mealchemy-backend-prod-otygypdv7a-ey.a.run.app The health endpoint returns UP. A browser hit to a protected route returns 403, which is Spring Security rejecting an unauthenticated request.
Backend (staging) https://mealchemy-backend-staging-otygypdv7a-ey.a.run.app Printed by the staging CD deploy-status step.
Engine (staging and production) Cloud Run URLs, not publicly reachable Reachable only from the backend's runtime identity.
Landing page https://mealchemy-firebase.web.app/ Firebase Hosting default domain.
Android app https://appdistribution.firebase.dev/i/9aea731b3a1ce2f6 Linked from the landing page. Testers accept the invite, sign in once, and install.
Brand style guide https://cos301-se-2026.github.io/Mealchemy/ Deploys automatically from the design-spec folder on main and dev.

3. Environments

Three environments, each running the same application against different data and under different service names.

Git branch Trigger Environment Cloud Run services Database Recipe photo bucket
dev none (local only) development local Docker Compose stack local Postgres container Not configured
deployment CD workflow staging mealchemy-backend-staging, mealchemy-engine-staging Neon staging branch mealchemy-recipe-photos-staging
main CD workflow production mealchemy-backend-production, mealchemy-engine-production Neon production branch mealchemy-recipe-photos-prod

Production deploys pass through a required-reviewer approval gate in GitHub Environments. A teammate must approve before any production job runs. Staging has no such gate. Cloud Run runs in europe-west3. Neon runs in eu-central-1. Same metro region, so cross-service latency is negligible.

4. Containerisation and provisioning

Every deployable surface is version-controlled.

  • Backend and engine containers. Both defined by Dockerfiles in the infrastructure folder. Each uses a multi-stage build: a builder stage that compiles the code, and a runtime stage that keeps only what the app needs to run.
  • Local development stack. A Docker Compose file starts Postgres, the backend, and optionally the engine and pgAdmin. Local runs use the same container structure as Cloud Run does in the cloud.
  • Cloud provisioning script. A single shell script sets up the GCP project APIs, creates the container registry, sets up the three service accounts, configures the GitHub-to-GCP authentication link, creates the recipe photo buckets, and creates empty secret containers. Running it against a fresh GCP project reproduces the entire cloud project. The script never prints or hardcodes a secret value.
  • Recipe photo buckets. Provisioning creates separate staging and production buckets in europe-west3 with Standard storage, uniform bucket-level access, public access prevention disabled, and soft delete disabled. The script is idempotent: it creates a missing bucket, then reapplies mutable bucket settings and IAM bindings on every run. Anonymous users receive object-read access without permission to list bucket contents. The backend runtime identity receives object access only on these two buckets and can sign as itself without a downloaded service-account key.

5. Secrets management

No credential is committed to the repository.

  • Local development uses a git-ignored env file. An example template lists field names only.
  • Cloud deployment stores all secrets in Google Secret Manager. Nine values in total: two database URLs per environment (pooled and non-pooled), a database username per environment, a database password per environment, and one JWT secret shared across environments. Secrets are injected when a container starts and never appear in the Docker image, the workflow file, or logs.
  • The JWT expiration time is not a secret. It is a plain configuration value.
  • Recipe photo bucket names and the signing service-account address are not secrets. The CD workflow selects the environment-specific bucket and injects both values into the backend Cloud Run service as plain configuration.

Access rules for each secret are covered in the next section.

6. Security

Every security mechanism at the deployment layer.

6.1 Three separate service accounts

The system uses three distinct identities. Each has permissions scoped to only what its job requires.

Account Used by What it can do What it cannot do
cd-deployer GitHub Actions at deploy time Create and update Cloud Run services, push images to the registry, read the six migration secrets Flyway needs Cannot read the running app's database URL or JWT secret
backend-runtime The running backend container Read its seven runtime secrets, call the engine service, create and delete objects in the two recipe photo buckets, and use signBlob on itself Cannot redeploy anything. Cannot touch the registry. Cannot administer buckets or access project-wide storage.
engine-runtime The running engine container Run the container Has no secret access at all. Cannot read a database credential even if one were written into its code.

Splitting the identities enforces two rules at the infrastructure level. The engine never touches the database, because it has no database credentials to use. The backend is the only caller that can reach the engine, because the engine only accepts calls from the backend-runtime identity. Both rules are also true in the application code, but the infrastructure keeps them true even if future code tries to bypass them.

signBlob elaboration: The backend runtime service account is granted permission to call iam.serviceAccounts.signBlob for its own identity. This allows Cloud Run to generate short-lived V4 upload URLs using Google-managed credentials without storing a downloadable service-account private key.

6.2 GitHub authenticates to GCP without a stored key

No long-lived GCP key is stored anywhere.

Each GitHub workflow run gets a short-lived signed token from GitHub that states which repository and organisation it came from. GCP verifies the token, checks that the organisation matches Mealchemy's own, and exchanges it for a short-lived GCP credential valid for a few minutes.

A downloaded GCP key would work forever until manually revoked. If it leaked (accidentally committed, exposed in a log), the vulnerability would have no expiry. This approach removes that risk entirely because no static key exists.

6.3 Engine is unreachable from the public internet

The engine is deployed as a private service. Cloud Run rejects any request without a valid signed identity token before any application code runs. This means the public internet, including a misbehaving Flutter client, gets a 403 error at the network layer. Only the backend-runtime account has permission to call the engine, so the backend is the only caller that can reach it.

6.4 Each secret has its own access rule

Each of the nine secrets has its own individual access rule. The backend-runtime account can read only its seven runtime secrets. The cd-deployer account can read only the six migration secrets. Neither can read a secret outside its list.

Secrets are injected as environment variables when a container starts. They are never written into the Docker image, the workflow file, or committed configuration. Any secret value fetched mid-job is registered as masked immediately, so it cannot appear in plain text in the workflow log.

6.5 CI security checks

Every pull request is scanned before it can be merged.

  • Gitleaks scans the full git history (not just the current diff) for committed credentials. Any secret found fails the pull request.
  • Trivy builds both the backend and engine Docker images and scans them for known vulnerabilities. A high or critical finding fails the build.
  • Every third-party GitHub Action is pinned to a specific commit hash, not a moving version tag. A moving tag can be changed by its publisher. A commit hash cannot. This blocks the class of attack where a compromised publisher pushes malicious code into a downstream pipeline.

6.6 Images are traceable to commits

Every image is tagged with the git commit hash that produced it. This makes an image one-to-one with the code it contains. The rollback workflow shifts traffic to a specific past revision by name, and Cloud Run resolves that revision to the exact image it was originally deployed with. The registry keeps the last five images per service, which sets the practical rollback window.

6.7 Human approval before production

Automated checks run without a person in the loop. Production deploys add one exception. GitHub pauses the workflow after all automated checks pass and waits for a teammate to approve before any production job runs. The same approval applies to a production rollback. Staging has no such requirement.

6.8 All network traffic is encrypted

Every network hop is encrypted.

  • The Flutter app talks to the backend over HTTPS with a JWT authentication token.
  • The Flutter app uploads recipe photos directly to Cloud Storage over HTTPS using a short-lived signed URL and retrieves displayed photos over HTTPS.
  • The backend talks to Neon over the standard PostgreSQL protocol with TLS required.
  • The backend calls the engine over HTTPS with a signed Cloud Run identity token that the engine verifies before responding.

The infrastructure side of the engine call is in place. The backend does not yet issue engine calls, so the code that fetches and attaches the token will be added when the first engine integration ships.

6.9 Android app signing

The Android release keystore (the private key used to sign the APK) is stored as a base64-encoded CI secret. Losing or leaking it is worse than a normal rotatable secret. A lost keystore means future updates cannot install over the existing app. A leaked one lets someone else sign a build that impersonates it.

The credential that uploads builds to Firebase App Distribution is scoped to a separate Firebase project that holds nothing else. It can push a build to testers. It cannot reach the database, Cloud Run, or any production secret, because those live in a different project. A leaked distribution token is a nuisance, not a way into production and therefore isolates the issue.

flowchart TB
    subgraph Setup["One-time setup"]
        S1["Generate keystore<br/>keytool, locally"]
        S2["base64 encode<br/>keystore.jks to text"]
        S3["Stored as GitHub secrets"]:::secret
        S1 --> S2
        S2 --> S3
    end

    subgraph Build["Every release build (frontend-delivery.yml, on the runner only)"]
        B1["Runner starts<br/>fresh, ephemeral"]
        B2["Decode keystore<br/>back to .jks, in-memory"]
        B3["Sign release APK<br/>flutter build release"]:::secret
        B4["Upload build<br/>App Distribution"]
        B5["Tester installs<br/>signed build"]
        B6["Runner discarded<br/>.jks never persisted"]
        B1 --> B2
        B2 --> B3
        B3 --> B4
        B4 --> B5
        B1 --> B6
    end

    S3 -.->|"KEYSTORE_BASE64 injected as env var"| B2
    W1["Losing this keystore means future updates<br/>can never install over the existing app again"]:::warning
    B5 -.-> W1

    classDef secret fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef warning fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
Loading

6.10 Recipe photo storage

Recipe photo uploads use a signed direct-upload flow so image bytes do not pass through the backend or Cloud Run request path.

  1. The authenticated Flutter client creates the recipe, then calls POST /recipes/{id}/photo-upload-url with the selected image's content type and size.
  2. The backend confirms that the recipe exists and that the authenticated user owns it. It accepts only JPEG, PNG, and WebP images greater than zero bytes and no larger than 5 MB.
  3. The backend creates a unique object name under recipes/{recipeId}/, then returns a V4 signed PUT URL, the permanent public photo URL, the required request headers, and the expiry time. The signed URL expires after 10 minutes and requires the declared Content-Type and Content-Length values.
  4. Flutter uploads the binary directly to Cloud Storage over HTTPS. Only after the upload succeeds does Flutter update the recipe's existing photoUrl field with the permanent URL.

The backend signs URLs through its Google-managed Cloud Run identity. Its service account has signBlob permission on itself, so no downloadable private key is created or stored. Its storage role is granted only on the two recipe photo buckets rather than across the GCP project.

Objects are deliberately public-read because the app displays the same photos repeatedly in lists and detail views and caches their permanent URLs. Public access uses roles/storage.legacyObjectReader, which permits retrieval of a known object but not listing bucket contents. Recipe visibility is still enforced when the backend returns recipe data, but anyone who obtains a raw photo URL can retrieve that photo without authentication. This is an accepted limitation of the public-read design.

When a photo is replaced or its recipe is deleted, the database transaction commits first. An after-commit event then asks Cloud Storage to delete the previous object. A cleanup failure is logged and does not reverse the successful recipe update or deletion, because an unreferenced storage object is safer than a recipe that points to a missing image.

7. Rollback strategy

The rollback workflow shifts traffic, not code. Every past Cloud Run revision already exists. Every image is tagged by git commit hash in the registry. Rolling back is a routing change measured in seconds, not a rebuild.

The workflow steps:

  1. Operator picks the environment, the service (backend, engine, or both), and the target revision name. Operator confirms the target revision is compatible with the current database schema.
  2. Runs behind the production reviewer gate.
  3. Shifts traffic for backend, engine, or both. Sequential if both.
  4. Confirms which revisions are now receiving traffic.
  5. Runs a post-rollback health check. HTTP call to the backend health endpoint with three retries to absorb a cold start. Cloud Run readiness check for the engine (which is not publicly reachable).

What rollback cannot do. It cannot roll back the database schema. Cloud Run revisions are swappable. Neon branches are not automatically reverted. Rollback is only safe when the destination revision runs against the current schema. For a destructive migration such as a dropped column or a new not-null constraint, the recovery is to roll forward with a fix. The workflow makes the operator confirm schema compatibility rather than assuming it.

Android has no equivalent. The rollback workflow works because Cloud Run does a traffic shift. A signed APK already installed on a tester's phone has no server-side traffic to redirect. Android's package installer refuses to install a lower version code over a higher one, so re-sharing an older build does not cleanly reinstall over a broken newer one. The only recovery path for a bad Android build is a forward fix: correct the issue, bump the version, push, and let testers install the next build as a normal update.

flowchart TB
    A["Something breaks<br/>after a deploy"] --> B["Trigger rollback<br/>manual dispatch"]
    B --> C["Operator supplies inputs<br/>env, service, revision, schema tick"]
    C --> D{"Inputs valid?<br/>all fields + tick"}
    D -.->|"no"| E["Blocked, nothing changes"]:::fail
    D -->|"yes"| F["Reviewer gate<br/>required approval for production"]:::gate
    F --> G["Shift traffic<br/>update traffic 100%"]
    G --> H["Confirm active<br/>revisions now serving"]
    H --> I["Health check<br/>backend + engine"]:::gate
    I -->|"healthy"| J["Rollback done<br/>old revision serving"]
    I -.->|"fails"| K["Fails and investigate"]:::fail

    N1["Caveat: only reverts the container.<br/>A schema breaking migration needs a forward fix."]:::note
    N2["Android has no equivalent.<br/>An installed APK cannot be pulled back.<br/>Recovery is always a forward fix rebuild."]:::note

    classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef gate fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef note fill:#fef3c7,stroke:#d97706,color:#78350f
Loading

8. Deployment diagram

Technology-specific view of where the system runs, what is deployed on each node, and how the pieces talk to each other with protocol and port. Staging and production share this topology. The only differences are the Cloud Run service names and which Neon branch is used.

---
config:
  layout: dagre
---

flowchart LR
 subgraph Legend["Legend"]
        L1["Solid arrow = runtime request traffic"]
        L2["Dashed arrow = deploy-time or install-time path"]
  end

 subgraph Device["Mobile device (iOS / Android)"]
        FL["Flutter app<br>signed APK"]
  end

 subgraph Runner["GitHub Actions runner (CD only)"]
        FW["Flyway container<br>migration tool"]
  end

 subgraph GCP["Cloud Run (europe-west3)"]
        BE["Backend service<br>Spring Boot container<br>public ingress, protected by JWT"]
        EN["Engine service<br>Python container<br>private ingress"]
  end

 subgraph Support["Supporting GCP services"]
        AR["Artifact Registry<br>Docker images tagged by git SHA"]
        SM["Secret Manager<br>DB credentials, JWT secret,<br>pooled and non-pooled URLs"]
        IAM["IAM Credentials API<br>signBlob for backend-runtime"]
        CS["Cloud Storage<br>staging or production<br>recipe photo bucket"]
  end

 subgraph GCPProj["GCP project: mealchemy"]
        GCP
        Support
  end

 subgraph FbApp["Firebase project: mealchemy-app"]
        FAD["Firebase App Distribution<br>signed Android build"]
  end

 subgraph FbHost["Firebase project: mealchemy-firebase"]
        FH["Firebase Hosting<br>static landing page"]
  end

 subgraph Neon["Neon, Postgres 16 (eu-central-1)"]
        DBP[("Staging or production branch<br>pooled endpoint<br>runtime traffic")]
        DBM[("Staging or production branch<br>non-pooled endpoint<br>Flyway migrations only")]
  end

    FL -- "API requests including signed-URL request<br>HTTPS + JWT, :443" --> BE
    BE -- "Signed PUT URL and permanent photo URL<br>HTTPS response, :443" --> FL

    BE -- "Generate V4 signature<br>HTTPS signBlob, :443" --> IAM
    FL -- "Photo bytes sent directly<br>V4 signed PUT over HTTPS, :443" --> CS
    FL -- "Display recipe photo<br>public HTTPS GET, :443" --> CS
    BE -- "Delete replaced or removed photos<br>Storage API over HTTPS, :443" --> CS

    BE -- "JDBC over TLS<br>:5432 pooled" --> DBP
    BE -- "HTTPS + signed identity token<br>:443" --> EN

    FW -. "JDBC over TLS, :5432 non-pooled<br>holds advisory lock" .-> DBM
    SM -. "Runtime secrets injected<br>at container start" .-> BE
    SM -. "Migration secrets fetched<br>at CD start" .-> FW
    AR -. "Container image pulled<br>at deployment" .-> BE
    AR -. "Container image pulled<br>at deployment" .-> EN

    FL -- "HTTPS, :443" --> FH
    FL -. "Install through invite link" .-> FAD

    L1:::legendNode
    L2:::legendNode
    FL:::appNode
    FW:::runnerNode
    BE:::gcpNode
    EN:::gcpNode
    AR:::supportNode
    SM:::supportNode
    IAM:::identityNode
    CS:::storageNode
    FAD:::firebaseNode
    FH:::firebaseNode
    DBP:::dbNode
    DBM:::dbNode

    classDef appNode stroke:#818cf8,fill:#eef2ff
    classDef gcpNode stroke:#38bdf8,fill:#f0f9ff
    classDef dbNode stroke:#4ade80,fill:#f0fdf4
    classDef firebaseNode stroke:#fb923c,fill:#fff7ed
    classDef supportNode stroke:#a78bfa,fill:#f5f3ff
    classDef identityNode stroke:#f59e0b,fill:#fffbeb
    classDef storageNode stroke:#14b8a6,fill:#f0fdfa
    classDef legendNode stroke:#94a3b8,fill:#f1f5f9,color:#334155
    classDef runnerNode stroke:#f472b6,fill:#fdf2f8

Loading
---
config:
  layout: dagre
---
flowchart TB
    K1["Mobile application = Flutter"]
    K2["Backend service = Spring Boot"]
    K3["Recommendation service = Python"]
    K4["Compute platform = Google Cloud Run"]
    K5["Database = Neon PostgreSQL 16"]
    K6["Photo object storage = Google Cloud Storage"]
    K7["Container registry = Google Artifact Registry"]
    K8["Secret storage = Google Secret Manager"]
    K9["Identity signing = Google IAM Credentials API"]
    K10["CI/CD runner = GitHub Actions"]
    K11["Database migration = Flyway"]
    K12["Android distribution = Firebase App Distribution"]
    K13["Landing-page hosting = Firebase Hosting"]

    classDef keyNode stroke:#64748b,fill:#ffffff,color:#1e293b
    class K1,K2,K3,K4,K5,K6,K7,K8,K9,K10,K11,K12,K13 keyNode

Loading

How to read it. The Flutter client only talks to the backend. The engine has no public ingress and is only reachable from the backend's identity, over a signed identity token. The backend is the only service with database credentials. The engine has none. The Flyway container runs only during a CD job and connects to a different Neon endpoint than the running app.

Three separate GCP and Firebase projects. The project holding the backend's Secret Manager credentials, the GitHub-to-GCP link, and the container registry is separate from the project used for Android distribution. That project is separate again from the project serving the landing page.

Project Purpose Why isolated
mealchemy Backend and engine, recipe photo storage, container registry, secrets, GitHub-to-GCP link Holds every production credential and both environment-specific recipe photo buckets.
mealchemy-app Android App Distribution only Its only credential can push a build to testers. Cannot touch the database, Cloud Run, or any production secret.
mealchemy-firebase Landing page hosting Fully static. Contains a button-link to download the Android app.

9. CI/CD pipeline diagram

Flow from commit to deployed artifact. Covers both trigger types (pull request and push), every stage with the tool behind it, where artifacts land, target environments, the manual approval gate, and failure paths.

---
config:
  layout: dagre
---
flowchart TB
    A["Commit pushed / PR opened"] --> B["ci.yml - trigger: pull_request"]
    B --> C["secret-scan (gitleaks)"]
    C --> D1["backend-lint (Checkstyle)"] & D2["engine-lint (Ruff)"] & D3["frontend-analyze (flutter analyze)"] & D4["docker-security-scan (build + Trivy)"]
    D1 --> D1a["backend-unit-tests (JUnit)"]
    D1a --> D1b["backend-integration-tests<br>JUnit + real Postgres, JaCoCo → Codecov"]
    D2 --> D2a["engine-typecheck (mypy --strict)"]
    D2a --> D2b["engine-tests (pytest)"]
    D3 --> D3a["frontend-tests<br>flutter test, lcov → Codecov"]
    D1b --> E{"All CI jobs green?"}
    D2b --> E
    D3a --> E
    D4 --> E
    E -- no --> F["PR blocked - merge disabled"]
    E -- yes --> G["Merge to deployment or main: 1 approval deployment, 2 approvals main"] & n1["Merge to dev: 1 approval"]
    G --> H["cd.yml - trigger: push"]
    H --> I["migrate-database<br>Flyway on Neon, non-pooled conn, SSL required"] & I2["Artifact Registry storage check"]
    I -- fails --> J["Chain stops - old revision keeps serving"]
    I -- passes --> K1["build-backend<br>image-size gate: warn 500MB / fail 700MB"] & K2["build-engine<br>image-size gate: warn 400MB / fail 600MB"]
    K1 --> L1["deploy-backend<br>push (Artifact Registry, tag=SHA, immutable) → gcloud run deploy"]
    K2 --> L2["deploy-engine<br>same pattern, private, grants backend run.invoker"]
    L1 --> M{"Target = production (main)?"}
    L2 --> M
    M -- yes --> N["Required-reviewer approval gate"]
    N --> O["cutover<br>routes 100% traffic to new revisions"]
    M -- "no, branch is staging" --> O
    O --> Q["deploy-status<br>prints live service URLs"]
    Q --> P["Live - Cloud Run backend + engine, Firebase Hosting (landing page, if changed)"]

     A:::triggerStage
     B:::triggerStage
     C:::ciStage
     D1:::ciStage
     D2:::ciStage
     D3:::ciStage
     D4:::ciStage
     D1a:::ciStage
     D1b:::ciStage
     D2a:::ciStage
     D2b:::ciStage
     D3a:::ciStage
     E:::decisionGate
     F:::failState
     G:::cdStage
     H:::cdStage
     I:::cdStage
     I2:::cdStage
     J:::failState
     K1:::deployStage
     K2:::deployStage
     L1:::deployStage
     L2:::deployStage
     M:::decisionGate
     N:::deployStage
     O:::deployStage
     Q:::liveStage
     P:::liveStage
    classDef triggerStage stroke:#818cf8,fill:#eef2ff
    classDef ciStage stroke:#a78bfa,fill:#f5f3ff
    classDef decisionGate stroke:#fb923c,fill:#fff7ed
    classDef cdStage stroke:#2dd4bf,fill:#f0fdfa
    classDef deployStage stroke:#4ade80,fill:#f0fdf4
    classDef liveStage stroke:#22d3ee,fill:#ecfeff
    classDef failState stroke:#f87171,fill:#fef2f2
    style n1 stroke:#2dd4bf,fill:#f0fdfa
Loading

10. Free-tier guardrails

Two quotas are at real risk under normal project activity. The rest (request counts, secret access operations, hosting bandwidth) are not reachable at this scale.

Quota Real risk Guardrail in place
Registry storage (0.5 GB) Ordinary commit cadence can exceed this in one to two weeks. A single bloated image can consume the whole quota. Keep-last-five cleanup policy handles accumulation. Multi-stage Docker builds and slim base images control bloat. A pre-push image size gate is the preventive control.
Cloud Run compute Only at risk if scale-to-zero is disabled. A single always-on instance can exhaust the month's compute in about two days. Scale-to-zero is on. Any change is a review red flag.
Neon compute (shared across staging and production) A connection pool that never lets the database go idle can burn over half the month's compute. The connection pool minimum-idle setting is zero, so idle periods let Neon suspend.

11. Catches we have noticed and planned out

  • Two different database connection strings exist on purpose. The running app uses the pooled connection (many short-lived app connections share a small number of real Postgres connections). Flyway uses the non-pooled connection, because migrations hold an advisory lock and pool sharing can hand the "connection" to a different session mid-transaction. Secret Manager stores both under different names, one for the app and one for Flyway.

  • A migration that passes CI is not proof it is safe against real data. CI tests migrations against an empty database. A migration that adds a not-null constraint passes cleanly against an empty table and fails the moment a real row has a null. The safe pattern for constraint tightening is a three-step split: add the column nullable, backfill existing rows, add the constraint once no nulls remain. The real safety check is running the migration against a copy-on-write Neon branch, then discarding it.

  • A cleanup policy and a size gate solve two different problems. Keep-last-five handles accumulation. The image size gate handles a single bloated artifact. Relying on only one leaves the other failure mode open.

  • Anything that keeps a free resource artificially active defeats scale-to-zero. A "keep it warm" mechanism (a periodic ping, a min-instances setting above zero, a connection pool that never releases idle connections) is the single biggest driver of avoidable compute cost. Both scale-to-zero and the pool minimum-idle settings are on.

  • A public recipe photo URL is not an authenticated resource. The backend controls whether a recipe response reveals the URL, and anonymous users cannot list the bucket. However, anyone who receives or discovers the exact URL can retrieve the object. Private-recipe photos therefore depend on the URL not being shared outside the authorised recipe response.

  • Storage cleanup is deliberately secondary to recipe consistency. Photo replacement and recipe deletion commit their database changes before removing the old object. If Cloud Storage deletion fails, the user-facing operation remains successful and the failure is logged. This can leave an unreferenced object that must be handled by a later bucket sweep.

12. Alternatives considered

The project must run on free-tier and open-source tooling with zero ongoing cost. Three principles drove every choice:

  • The free tier must not expire.
  • Infrastructure enforces the architecture rather than just permitting it.
  • The identity that deploys the system is not the identity that runs it.

13.1 Summary

Alternative Verdict One-line reason
Render managed Postgres, and similar free DBs Rejected Free instance expires after a fixed window.
AWS free tier Rejected 12-month offer, not permanent, and has limited free-tier usage
Firestore Rejected The data model is relational; Firestore has no joins, foreign keys, or cascading delete.
Firebase Authentication Rejected Different token format and SDK; would mean replacing a working JWT + BCrypt stack.
Cloud Functions Rejected Test suite runs against real Postgres in CI; this would need a full rewrite.
Docker Hub as primary registry Documented fallback Pull-rate limits, short-lived cache behaviour on Cloud Run.
GitHub Container Registry as primary Documented fallback Cloud Run does not deploy from it directly.
Paying a small storage overage Contingency Trivial cost, but breaks the zero-cost constraint.
Firebase Storage for recipe images Rejected Its main advantage depends on Firebase Authentication, which is not used.

13.2 Detail

Render managed Postgres, and similar free-database offers. The free instance expires after a fixed window (commonly around 90 days).

AWS free tier. Most services on the AWS free tier are a 12-month offer, not permanent.

Firestore, Firebase Authentication, and Cloud Functions. Not adopted despite using other parts of Firebase.

  • The data model is relational (users, vaults, folders, recipes, ingredients, with foreign keys and cascading deletes). Firestore has no joins, no foreign keys, no cascading delete.
  • Firebase Authentication uses a different token format and different SDK, so adopting it would mean deleting the working JWT and BCrypt stack, not adapting it.
  • The test suite runs against real Postgres in CI. Moving logic into Cloud Functions would mean rewriting those tests.

Docker Hub as the primary registry. Cloud Run can pull from it, but Google only recommends it for popular official images. Docker Hub has pull-rate limits and short-lived cache behaviour on Cloud Run. Kept as a documented fallback if Artifact Registry becomes the binding constraint.

GitHub Container Registry as the primary registry. Not usable directly, because Cloud Run only deploys from Artifact Registry or Docker Hub. Documented as a fallback via an Artifact Registry remote repository pointing at GHCR. The authoritative image would live in GHCR's larger free quota and Artifact Registry would only cache it.

Paying a small storage overage. Trivial cost (around $0.10 to $0.20 per month for one to two extra GB), but breaks the zero-cost constraint. Would be documented as a justified exception if used and a last alternative.

Firebase Storage for recipe images. Its main advantage is security rules that authenticate the uploader, which depends on Firebase Authentication. This system does not use Firebase Authentication, so those rules cannot identify a user. Authorisation would route back through the backend anyway. Plain Google Cloud Storage was chosen instead: the backend issues a signed upload URL, Flutter uploads the image binary directly to the bucket, and only the resulting URL is stored in the existing photo URL column. No schema change, no binary touches Postgres or the backend request path.


updated 17 August 2026

Mealchemy

Team PUL5E · COS 301


Documentation

Demo 4
Demo 3
Demo 2
Demo 1

Setup Guides


Quick Links


Updated: 13 September 2026

Clone this wiki locally