Repository navigation
Deployment Demo 4
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.
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. |
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. |
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.
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.
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.
Every security mechanism at the deployment layer.
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.
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.
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.
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.
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.
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.
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.
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.
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
Recipe photo uploads use a signed direct-upload flow so image bytes do not pass through the backend or Cloud Run request path.
- The authenticated Flutter client creates the recipe, then calls POST /recipes/{id}/photo-upload-url with the selected image's content type and size.
- 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.
- 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.
- 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.
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:
- 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.
- Runs behind the production reviewer gate.
- Shifts traffic for backend, engine, or both. Sequential if both.
- Confirms which revisions are now receiving traffic.
- 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
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
---
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
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. |
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
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. |
-
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.
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.
| 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. |
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
· Team PUL5E · COS 301 · University of Pretoria · 2026 ·
Team PUL5E · COS 301
Demo 4
-
Software Requirements Specifications (SRS)
-
Software Architecture Specifications (SAS)
Demo 3
-
Software Requirements Specifications (SRS)
-
Software Architecture Specifications (SAS)
Demo 2
-
Software Requirements Specifications (SRS)
-
Software Architecture Specifications (SAS)
Demo 1
- Docker Setup
- CI Setup
- ACT Setup
- pgAdmin Setup
- Security Setup
- Render Setup
- Design Spec Deployment
- Wiki Submodule
Updated: 13 September 2026