Compare commits
3 Commits
d04f6a8a69
...
816e037a9a
| Author | SHA1 | Date | |
|---|---|---|---|
| 816e037a9a | |||
| 91bdbd5289 | |||
| be20eee16b |
65
.github/workflows/README.md
vendored
Normal file
65
.github/workflows/README.md
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# CI Workflows
|
||||||
|
|
||||||
|
## `build.yml` — parameterized build
|
||||||
|
|
||||||
|
Manually-triggered, **build-only** pipeline (no push, no deploy). Produces downloadable artifacts.
|
||||||
|
|
||||||
|
### How to run
|
||||||
|
1. GitHub → **Actions** tab → **Build** (left sidebar) → **Run workflow**.
|
||||||
|
2. Pick:
|
||||||
|
- **environment** — `staging` or `prod`
|
||||||
|
- **target** — `all`, `customer`, `mitra`, `backend`, or `control_center`
|
||||||
|
- **platform** — `android`, `ios`, or `both` (only affects the customer/mitra apps; ignored for backend/control_center)
|
||||||
|
3. **Run workflow**. Open the run; artifacts appear at the bottom of the summary page when it finishes.
|
||||||
|
|
||||||
|
(You can also trigger it from the CLI: `gh workflow run build.yml -f environment=staging -f target=all -f platform=android`.)
|
||||||
|
|
||||||
|
### Runner split (Linux vs macOS)
|
||||||
|
iOS can only be built on macOS, so the workflow routes jobs accordingly:
|
||||||
|
|
||||||
|
| Job | Runner |
|
||||||
|
|---|---|
|
||||||
|
| `customer-ios`, `mitra-ios` | `macos-latest` |
|
||||||
|
| `customer-android`, `mitra-android`, `backend`, `control_center` | `ubuntu-latest` |
|
||||||
|
|
||||||
|
> ⚠️ **macOS runner minutes bill at ~10× Linux.** That's why `platform` defaults to `android`. Choose `ios`/`both` deliberately.
|
||||||
|
|
||||||
|
### What each target produces
|
||||||
|
|
||||||
|
| Target | Platform | Tool | Artifact | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `customer` | android | Flutter | `customer-<env>-android-apk` → `app-<env>-release.apk` | Debug-signed (no keystore in repo). Fine for internal/Firebase App Distribution. |
|
||||||
|
| `customer` | ios | Flutter | `customer-<env>-ios-app` (`Runner.app`) | **Unsigned** (`--no-codesign`). See iOS prerequisites below. |
|
||||||
|
| `mitra` | android | Flutter | `mitra-<env>-android-apk` | Same as customer-android. |
|
||||||
|
| `mitra` | ios | Flutter | `mitra-<env>-ios-app` | Same caveats as customer-ios. |
|
||||||
|
| `control_center` | — | Vite | `control-center-<env>-dist` (the `dist/` folder) | `VITE_API_BASE_URL` baked in at build time — see below. |
|
||||||
|
| `backend` | — | Docker | `backend-<env>-image` (`*.tar.gz`) | Env-**agnostic** image (config is runtime env vars). Load with `docker load`. |
|
||||||
|
|
||||||
|
`all` runs every selected job in parallel; each is independent, so one failing doesn't block the others.
|
||||||
|
|
||||||
|
### ⚠️ iOS prerequisites (not satisfied yet)
|
||||||
|
The iOS jobs are wired but will **fail until two things are done**:
|
||||||
|
1. **Per-flavor Xcode schemes** (`staging`, `prod`) must exist in `ios/Runner.xcodeproj`. Today there's only the default `Runner` scheme, so `flutter build ios --flavor staging` errors with *"The Xcode project does not define custom schemes"*. This mirrors the Android flavor setup but on the iOS side (schemes + build configurations + per-config bundle IDs).
|
||||||
|
2. **Code signing** for a distributable `.ipa`: Apple Developer certificate + provisioning profiles stored as GitHub secrets. The current jobs use `--no-codesign`, which only validates that the app **compiles** and produces an unsigned `Runner.app` (not installable on devices).
|
||||||
|
|
||||||
|
Until then, `platform=ios`/`both` is useful only once the schemes land. `platform=android` works today.
|
||||||
|
|
||||||
|
### Environment specifics
|
||||||
|
- **Apps (customer/mitra):** `environment` selects the Flutter flavor + entrypoint + `env/<env>.json` (`--flavor`, `-t lib/main_<env>.dart`, `--dart-define-from-file`). Reminder: `env/staging.json` still has a **placeholder API URL** until the staging backend is deployed.
|
||||||
|
- **control_center:** `VITE_API_BASE_URL` is compiled in. Defaults: prod → `https://internal.halobestie.com`, staging → `https://staging-internal.halobestie.com`. Override without editing the workflow by setting repo **Variables** (Settings → Secrets and variables → Actions → Variables): `CC_API_BASE_URL_PROD`, `CC_API_BASE_URL_STAGING`.
|
||||||
|
- **backend:** the image is identical across environments; env/secrets are supplied at `docker run` time (see [backend/DEPLOY.md](../../backend/DEPLOY.md)). The `<env>` in the artifact name is just a label.
|
||||||
|
|
||||||
|
### Deploying a build artifact (manual, since we don't push yet)
|
||||||
|
- **Backend:** download `backend-<env>-image`, copy to the host, then:
|
||||||
|
```bash
|
||||||
|
gunzip -c halobestie-backend-<env>.tar.gz | docker load
|
||||||
|
# then run per backend/DEPLOY.md
|
||||||
|
```
|
||||||
|
- **control_center:** download `control-center-<env>-dist`, serve the `dist/` behind Nginx (internal-only).
|
||||||
|
- **Apps:** download the APK and install / upload to Firebase App Distribution.
|
||||||
|
|
||||||
|
### Pinned versions
|
||||||
|
Flutter `3.41.9`, JDK `17`, Node `20` (see `env:` block in [build.yml](build.yml)). Bump them there when the team upgrades.
|
||||||
|
|
||||||
|
### Not included yet (by design)
|
||||||
|
Pushing images to a registry and deploying are intentionally out of scope for this first iteration. When you're ready, the natural next step is a `release.yml` that pushes to GHCR and (optionally, via a self-hosted runner or SSH) deploys to the VPS.
|
||||||
258
.github/workflows/build.yml
vendored
Normal file
258
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
# Manually triggered, parameterized build pipeline (build-only — no push/deploy).
|
||||||
|
#
|
||||||
|
# Trigger: GitHub → Actions tab → "Build" → "Run workflow" → pick:
|
||||||
|
# environment: staging | prod (which flavor/config to build)
|
||||||
|
# target: all | customer | mitra | backend | control_center
|
||||||
|
# platform: android | ios | both (only affects the customer/mitra apps)
|
||||||
|
#
|
||||||
|
# Runner split: iOS app jobs run on macOS runners (iOS can't build on Linux);
|
||||||
|
# everything else (Android, backend, control_center) runs on Linux.
|
||||||
|
#
|
||||||
|
# Outputs land as downloadable artifacts on the run summary page.
|
||||||
|
# See .github/workflows/README.md for details.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Build environment"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: staging
|
||||||
|
options: [staging, prod]
|
||||||
|
target:
|
||||||
|
description: "What to build"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: all
|
||||||
|
options: [all, customer, mitra, backend, control_center]
|
||||||
|
platform:
|
||||||
|
description: "Mobile platform (apps only; macOS runners cost ~10x)"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: android
|
||||||
|
options: [android, ios, both]
|
||||||
|
|
||||||
|
run-name: "Build ${{ inputs.target }} (${{ inputs.environment }}, ${{ inputs.platform }})"
|
||||||
|
|
||||||
|
# Cancel an in-flight run of the same target+env+platform when a new one starts.
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ inputs.target }}-${{ inputs.environment }}-${{ inputs.platform }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLUTTER_VERSION: "3.41.9" # keep in sync with the team's local Flutter
|
||||||
|
JAVA_VERSION: "17" # AGP 8 requires JDK 17
|
||||||
|
NODE_VERSION: "20" # matches backend engines + control_center
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Expand the `target` input into a JSON list the build jobs gate on.
|
||||||
|
# `all` → every component; otherwise just the one chosen.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
targets: ${{ steps.expand.outputs.targets }}
|
||||||
|
steps:
|
||||||
|
- id: expand
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.target }}" = "all" ]; then
|
||||||
|
echo 'targets=["customer","mitra","backend","control_center"]' >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo 'targets=["${{ inputs.target }}"]' >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- run: echo "Building → ${{ steps.expand.outputs.targets }} (env=${{ inputs.environment }}, platform=${{ inputs.platform }})"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# customer app — ANDROID (Linux runner). Debug-signed (no keystore in repo).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
customer-android:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'customer') && (inputs.platform == 'android' || inputs.platform == 'both')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: client_app
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: Build APK
|
||||||
|
run: |
|
||||||
|
flutter build apk \
|
||||||
|
--flavor ${{ inputs.environment }} \
|
||||||
|
-t lib/main_${{ inputs.environment }}.dart \
|
||||||
|
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: customer-${{ inputs.environment }}-android-apk
|
||||||
|
path: client_app/build/app/outputs/flutter-apk/app-${{ inputs.environment }}-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# customer app — iOS (macOS runner). Unsigned build (--no-codesign).
|
||||||
|
# PREREQ: per-flavor Xcode schemes must exist (staging/prod). Until then this
|
||||||
|
# fails with "The Xcode project does not define custom schemes". Distributable
|
||||||
|
# IPAs additionally need signing certs/profiles as secrets — not set up yet.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
customer-ios:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'customer') && (inputs.platform == 'ios' || inputs.platform == 'both')
|
||||||
|
runs-on: macos-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: client_app
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: Build iOS (unsigned)
|
||||||
|
run: |
|
||||||
|
flutter build ios --no-codesign \
|
||||||
|
--flavor ${{ inputs.environment }} \
|
||||||
|
-t lib/main_${{ inputs.environment }}.dart \
|
||||||
|
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: customer-${{ inputs.environment }}-ios-app
|
||||||
|
path: client_app/build/ios/iphoneos/Runner.app
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# mitra app — ANDROID (Linux runner).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mitra-android:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'mitra') && (inputs.platform == 'android' || inputs.platform == 'both')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: mitra_app
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: Build APK
|
||||||
|
run: |
|
||||||
|
flutter build apk \
|
||||||
|
--flavor ${{ inputs.environment }} \
|
||||||
|
-t lib/main_${{ inputs.environment }}.dart \
|
||||||
|
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mitra-${{ inputs.environment }}-android-apk
|
||||||
|
path: mitra_app/build/app/outputs/flutter-apk/app-${{ inputs.environment }}-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# mitra app — iOS (macOS runner). Same PREREQ caveats as customer-ios.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mitra-ios:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'mitra') && (inputs.platform == 'ios' || inputs.platform == 'both')
|
||||||
|
runs-on: macos-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: mitra_app
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: Build iOS (unsigned)
|
||||||
|
run: |
|
||||||
|
flutter build ios --no-codesign \
|
||||||
|
--flavor ${{ inputs.environment }} \
|
||||||
|
-t lib/main_${{ inputs.environment }}.dart \
|
||||||
|
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mitra-${{ inputs.environment }}-ios-app
|
||||||
|
path: mitra_app/build/ios/iphoneos/Runner.app
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# control_center — Vite SPA static build (Linux). VITE_API_BASE_URL is baked
|
||||||
|
# in at build time. Override defaults via repo Variables CC_API_BASE_URL_PROD
|
||||||
|
# / CC_API_BASE_URL_STAGING (Settings → Secrets and variables → Actions).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
control_center:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'control_center')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: control_center
|
||||||
|
env:
|
||||||
|
VITE_API_BASE_URL: >-
|
||||||
|
${{ inputs.environment == 'prod'
|
||||||
|
&& (vars.CC_API_BASE_URL_PROD || 'https://internal.halobestie.com')
|
||||||
|
|| (vars.CC_API_BASE_URL_STAGING || 'https://staging-internal.halobestie.com') }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: control_center/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- name: Build (VITE_API_BASE_URL=${{ env.VITE_API_BASE_URL }})
|
||||||
|
run: npm run build
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: control-center-${{ inputs.environment }}-dist
|
||||||
|
path: control_center/dist
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# backend — Docker image (Linux). Env-AGNOSTIC (config is runtime env vars),
|
||||||
|
# built once and exported as a loadable tarball. Deploy without a registry:
|
||||||
|
# gunzip -c halobestie-backend-*.tar.gz | docker load
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
backend:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'backend')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build image
|
||||||
|
run: docker build -t halobestie-backend:${{ inputs.environment }} ./backend
|
||||||
|
- name: Export image as tarball
|
||||||
|
run: docker save halobestie-backend:${{ inputs.environment }} | gzip > halobestie-backend-${{ inputs.environment }}.tar.gz
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backend-${{ inputs.environment }}-image
|
||||||
|
path: halobestie-backend-${{ inputs.environment }}.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
22
backend/.dockerignore
Normal file
22
backend/.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Deps are reinstalled inside the image via `npm ci`
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Secrets / local env — mounted at runtime, never baked in
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# VCS + tooling
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Tests + coverage (not needed in the runtime image)
|
||||||
|
coverage
|
||||||
|
docker-compose*.yml
|
||||||
|
**/*.test.js
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
*.md
|
||||||
@@ -10,7 +10,7 @@ Fastify.js REST API serving both mobile apps and the internal control center.
|
|||||||
- **Database:** PostgreSQL via GCP Cloud SQL
|
- **Database:** PostgreSQL via GCP Cloud SQL
|
||||||
- **Auth:** Self-managed JWT (HS256 access, 1h) + opaque refresh token (30d, rotated, bcrypt-hashed in `auth_sessions`). Firebase Auth removed in Phase 3.4 (commit `f860ab6`). `firebase-admin` is kept but only for FCM messaging.
|
- **Auth:** Self-managed JWT (HS256 access, 1h) + opaque refresh token (30d, rotated, bcrypt-hashed in `auth_sessions`). Firebase Auth removed in Phase 3.4 (commit `f860ab6`). `firebase-admin` is kept but only for FCM messaging.
|
||||||
- **Payment:** Xendit
|
- **Payment:** Xendit
|
||||||
- **Infra:** GCP Cloud Run
|
- **Infra:** Self-hosted Docker (VPS / Kubernetes / Docker Engine) — **not** Cloud Run. Multi-stage [Dockerfile](Dockerfile); deploy + log runbook in [DEPLOY.md](DEPLOY.md). DB is PostgreSQL (managed or self-hosted).
|
||||||
|
|
||||||
## Two Listeners
|
## Two Listeners
|
||||||
|
|
||||||
|
|||||||
209
backend/DEPLOY.md
Normal file
209
backend/DEPLOY.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# Backend — Docker Deployment Guide
|
||||||
|
|
||||||
|
Operational guide for building, deploying, and observing the Halo Bestie backend as a **Docker container** on self-hosted infra (VPS, Docker Engine, or Kubernetes). **Not** Cloud Run / serverless.
|
||||||
|
|
||||||
|
> Architecture / env-var reference: [../requirement/deployment.md](../requirement/deployment.md) · [.env.example](.env.example)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What gets deployed
|
||||||
|
|
||||||
|
A single image (multi-stage [Dockerfile](Dockerfile)) running `node src/server.js`, which starts **two listeners** ([src/server.js](src/server.js)):
|
||||||
|
|
||||||
|
| Listener | Bind | Port | Exposed? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Public API (`client_app` + `mitra_app`) | `0.0.0.0` | `PUBLIC_PORT` (default **3000**) | **Yes** — publish this |
|
||||||
|
| Internal API (control center) | `INTERNAL_HOST` (default `127.0.0.1`) | `INTERNAL_PORT` (default 3001) | **No** — loopback only, never publish |
|
||||||
|
|
||||||
|
Runtime image: Node 20 (bookworm-slim), prod-only deps, runs as non-root `node`, native `bcrypt` precompiled in the build stage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Install Docker (one-time, on the host)
|
||||||
|
|
||||||
|
### Ubuntu / Debian VPS
|
||||||
|
```bash
|
||||||
|
# Remove any old packages, then install Docker Engine from the official repo
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
|
||||||
|
# Run docker as your user without sudo (log out/in afterward)
|
||||||
|
sudo usermod -aG docker "$USER"
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
docker version
|
||||||
|
docker compose version # Compose v2 ships as a plugin with modern Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes
|
||||||
|
No Docker Engine needed on nodes — just push the image to a registry your cluster can pull from (see §4) and apply your manifests (§7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configure environment
|
||||||
|
|
||||||
|
Create `backend/.env.staging` (or `.env.production`) from the template — **never commit it**:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.staging
|
||||||
|
```
|
||||||
|
Fill in at minimum (full list in [.env.example](.env.example)):
|
||||||
|
|
||||||
|
| Var | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `PUBLIC_PORT` | `3000` (keep default unless your proxy expects otherwise) |
|
||||||
|
| `INTERNAL_HOST` / `INTERNAL_PORT` | leave default `127.0.0.1:3001` — keeps control center private |
|
||||||
|
| `DATABASE_URL` | Postgres connection string |
|
||||||
|
| `VALKEY_URL` | `redis://<host>:6379` |
|
||||||
|
| `SERVER_TZ` | `UTC` |
|
||||||
|
| `AUTH_JWT_SECRET` | **fresh per environment** — never reuse prod's |
|
||||||
|
| `FIREBASE_SERVICE_ACCOUNT_PATH` | path to the **mounted** SA JSON, e.g. `/secrets/firebase-sa.json`. Must be from the env's Firebase project (staging = `my-bestie-876ec`) |
|
||||||
|
| `XENDIT_ENABLED` | `false` until test keys + webhook are wired |
|
||||||
|
| `CC_ORIGIN`, `ADMIN_EMAIL`, `ADMIN_PASSWORD` | control-center access |
|
||||||
|
|
||||||
|
> Secrets (`.env`, the Firebase SA JSON) are provided at **runtime** via `--env-file` / volume mounts / k8s Secrets. They are **not** baked into the image (`.dockerignore` excludes `.env*`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Build & push the image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repo root
|
||||||
|
docker build -t <registry>/halobestie-backend:staging ./backend
|
||||||
|
|
||||||
|
# Push to your registry (Docker Hub, GHCR, GCP Artifact Registry, self-hosted, …)
|
||||||
|
docker push <registry>/halobestie-backend:staging
|
||||||
|
```
|
||||||
|
For a purely single-host setup you can skip the registry and build directly on the host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Run database migrations (one-off)
|
||||||
|
|
||||||
|
Run **before** (re)starting the service. Never auto-migrate on container boot — concurrent replicas would race.
|
||||||
|
```bash
|
||||||
|
# Migrate (every deploy that includes new migrations)
|
||||||
|
docker run --rm --env-file backend/.env.staging \
|
||||||
|
<registry>/halobestie-backend:staging node src/db/migrate.js
|
||||||
|
|
||||||
|
# Seed (first deploy only)
|
||||||
|
docker run --rm --env-file backend/.env.staging \
|
||||||
|
<registry>/halobestie-backend:staging node src/db/seed.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Deploy — plain Docker Engine
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name halobestie-staging \
|
||||||
|
--env-file backend/.env.staging \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v /opt/halobestie/secrets/firebase-sa.json:/secrets/firebase-sa.json:ro \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--log-driver json-file --log-opt max-size=10m --log-opt max-file=5 \
|
||||||
|
<registry>/halobestie-backend:staging
|
||||||
|
```
|
||||||
|
- Publish **only** `3000`. Do **not** map `3001`.
|
||||||
|
- `--log-opt` enables log rotation — see §8.
|
||||||
|
- Put a TLS-terminating reverse proxy (Nginx / Traefik / Caddy) in front for `https://staging-api.halobestie.com`. WebSocket upgrade must be proxied (the apps use `/api/shared/ws`).
|
||||||
|
|
||||||
|
### Or with Docker Compose
|
||||||
|
A ready-to-use [docker-compose.staging.yml](docker-compose.staging.yml) is included (backend only — Postgres/Valkey are expected via `DATABASE_URL`/`VALKEY_URL`). It publishes only `3000`, mounts the Firebase SA + log volume, and sets json-file rotation. Point it at your image via the `BACKEND_IMAGE` env var (or uncomment `build: .` to build on the host):
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
cp .env.example .env.staging # then fill it in
|
||||||
|
BACKEND_IMAGE=<registry>/halobestie-backend:staging \
|
||||||
|
docker compose -f docker-compose.staging.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deploy — Kubernetes (sketch)
|
||||||
|
|
||||||
|
- **Deployment** with the image, `envFrom` a Secret/ConfigMap, the Firebase SA JSON mounted from a Secret volume at `/secrets/firebase-sa.json`.
|
||||||
|
- **Service** exposing only container port `3000`. Liveness/readiness probes: `tcpSocket: { port: 3000 }` (no HTTP health route — TCP probe matches the Dockerfile HEALTHCHECK).
|
||||||
|
- Migrations: a one-off **Job** (`command: ["node","src/db/migrate.js"]`) run before rolling out, not an initContainer on every pod.
|
||||||
|
- Logs: pods write to stdout (§8) — your cluster's node agent (Fluent Bit / Loki / Cloud Logging) collects them automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Logs — where they go and how to map them
|
||||||
|
|
||||||
|
### 8a. Application logs → **stdout/stderr** (the Docker-native way)
|
||||||
|
The backend uses Fastify's pino logger (`logger: true` in [src/app.public.js](src/app.public.js) / [src/app.internal.js](src/app.internal.js)), emitting **structured JSON to stdout**, plus a few `console.log` lifecycle lines. It does **not** write its own app-log files. So:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tail live
|
||||||
|
docker logs -f halobestie-staging
|
||||||
|
|
||||||
|
# Last 200 lines
|
||||||
|
docker logs --tail 200 halobestie-staging
|
||||||
|
|
||||||
|
# Pretty-print the JSON (pino output is one JSON object per line)
|
||||||
|
docker logs -f halobestie-staging | jq .
|
||||||
|
|
||||||
|
# Compose equivalent
|
||||||
|
docker compose -f docker-compose.staging.yml logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rotation (important — default json-file logs grow unbounded):**
|
||||||
|
set it per-container as in §6 (`--log-opt max-size=10m --log-opt max-file=5`), or globally in `/etc/docker/daemon.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"log-driver": "json-file",
|
||||||
|
"log-opts": { "max-size": "10m", "max-file": "5" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
then `sudo systemctl restart docker`.
|
||||||
|
|
||||||
|
**Ship logs off-host (optional):** point Docker at a log driver instead of/alongside json-file — e.g. `--log-driver=loki`, `--log-driver=fluentd`, `--log-driver=syslog`, or `--log-driver=gcplogs`. On k8s, stdout is collected by the node logging agent; no per-container config needed.
|
||||||
|
|
||||||
|
**Persist raw stdout to a host file (simple VPS option):**
|
||||||
|
```bash
|
||||||
|
docker run -d --name halobestie-staging ... \
|
||||||
|
<image> > /var/log/halobestie/backend.log 2>&1
|
||||||
|
# better: use the json-file driver (above) and read /var/lib/docker/containers/<id>/<id>-json.log,
|
||||||
|
# or redirect via your reverse proxy / a sidecar. Prefer a real log driver for rotation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8b. Xendit webhook fallback JSONL → **needs a volume** (only if enabled)
|
||||||
|
The one component that writes a **file** is the optional webhook fallback sink ([src/services/webhook-log.service.js](src/services/webhook-log.service.js)), **off by default**. When `XENDIT_WEBHOOK_FALLBACK_ENABLED=true`, it writes rolling JSONL to `XENDIT_WEBHOOK_FALLBACK_DIR` (default `./logs` → `/app/logs` in the container). To keep those across restarts, **mount a volume**:
|
||||||
|
```bash
|
||||||
|
docker run -d ... \
|
||||||
|
-e XENDIT_WEBHOOK_FALLBACK_ENABLED=true \
|
||||||
|
-e XENDIT_WEBHOOK_FALLBACK_DIR=/app/logs \
|
||||||
|
-v /opt/halobestie/logs:/app/logs \
|
||||||
|
<image>
|
||||||
|
```
|
||||||
|
(The Compose example in §6 already declares the `backend-logs` volume for this.) If the fallback stays disabled, you don't need this volume — everything is on stdout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Health, upgrade, rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health — the image has a built-in TCP HEALTHCHECK; check it:
|
||||||
|
docker inspect --format '{{.State.Health.Status}}' halobestie-staging
|
||||||
|
|
||||||
|
# Upgrade to a new image
|
||||||
|
docker pull <registry>/halobestie-backend:staging
|
||||||
|
docker run ... node src/db/migrate.js # if new migrations
|
||||||
|
docker stop halobestie-staging && docker rm halobestie-staging
|
||||||
|
docker run -d --name halobestie-staging ... # re-run with the new image
|
||||||
|
|
||||||
|
# Rollback = re-run the previous tag/digest. Graceful shutdown is handled:
|
||||||
|
# server.js traps SIGTERM and drains the listeners before exit.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Quick reference
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|---|---|
|
||||||
|
| Build | `docker build -t <img> ./backend` |
|
||||||
|
| Migrate | `docker run --rm --env-file .env.staging <img> node src/db/migrate.js` |
|
||||||
|
| Run | `docker run -d --name halobestie-staging --env-file .env.staging -p 3000:3000 --restart unless-stopped <img>` |
|
||||||
|
| Logs (live) | `docker logs -f halobestie-staging` |
|
||||||
|
| Logs (pretty) | `docker logs -f halobestie-staging \| jq .` |
|
||||||
|
| Health | `docker inspect --format '{{.State.Health.Status}}' halobestie-staging` |
|
||||||
|
| Shell in | `docker exec -it halobestie-staging sh` |
|
||||||
44
backend/Dockerfile
Normal file
44
backend/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 1 — builder: install production deps, compiling native addons (bcrypt)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Toolchain required to compile native modules (bcrypt) when no prebuilt
|
||||||
|
# binary matches the platform. Lives only in this stage.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install against the lockfile for reproducible builds. Copy manifests first
|
||||||
|
# so this layer caches until deps actually change.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 2 — runtime: slim image with only prod node_modules + app source
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:20-bookworm-slim AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Compiled node_modules (same base image → ABI-compatible bcrypt .node binary).
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY package.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Drop privileges — node:* images ship a non-root `node` user.
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# Public listener only. INTERNAL_PORT (3001) binds to 127.0.0.1 inside the
|
||||||
|
# container by default and is intentionally NOT published.
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# No HTTP health route exists — probe the TCP port directly so the check is
|
||||||
|
# route-agnostic. Orchestrators (k8s) can override with their own probes.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||||
|
CMD node -e "require('net').connect({port: process.env.PUBLIC_PORT||3000, host:'127.0.0.1'}).on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
39
backend/docker-compose.staging.yml
Normal file
39
backend/docker-compose.staging.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Staging deploy for the Halo Bestie backend (self-hosted Docker).
|
||||||
|
# Usage:
|
||||||
|
# cd backend
|
||||||
|
# docker compose -f docker-compose.staging.yml up -d
|
||||||
|
#
|
||||||
|
# Prereqs: a populated .env.staging (cp .env.example .env.staging) and the
|
||||||
|
# Firebase service-account JSON at the mounted host path below. See DEPLOY.md.
|
||||||
|
#
|
||||||
|
# This runs ONLY the backend. Postgres + Valkey are expected to be reachable
|
||||||
|
# via DATABASE_URL / VALKEY_URL in .env.staging (managed/self-hosted elsewhere).
|
||||||
|
# TLS termination + the public hostname are handled by a reverse proxy in front.
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: ${BACKEND_IMAGE:-halobestie-backend:staging}
|
||||||
|
# To build on the host instead of pulling a pushed image, comment out
|
||||||
|
# `image:` above and uncomment:
|
||||||
|
# build: .
|
||||||
|
container_name: halobestie-staging
|
||||||
|
env_file: .env.staging
|
||||||
|
ports:
|
||||||
|
- "3000:3000" # public listener only — never publish 3001
|
||||||
|
volumes:
|
||||||
|
# Firebase service-account JSON (must match the env's Firebase project,
|
||||||
|
# staging = my-bestie-876ec). FIREBASE_SERVICE_ACCOUNT_PATH in .env.staging
|
||||||
|
# must equal the in-container path on the right.
|
||||||
|
- /opt/halobestie/secrets/firebase-sa.json:/secrets/firebase-sa.json:ro
|
||||||
|
# Optional: only needed if XENDIT_WEBHOOK_FALLBACK_ENABLED=true (writes
|
||||||
|
# rolling JSONL to /app/logs). App logs themselves go to stdout — see DEPLOY.md §8.
|
||||||
|
- backend-logs:/app/logs
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend-logs:
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
"description": "Halo Bestie backend API",
|
"description": "Halo Bestie backend API",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
|
|||||||
96
client_app/lib/core/widgets/web_page_screen.dart
Normal file
96
client_app/lib/core/widgets/web_page_screen.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
import '../theme/halo_tokens.dart';
|
||||||
|
|
||||||
|
/// Generic in-app WebView host for static external pages (privacy policy,
|
||||||
|
/// terms & conditions, etc).
|
||||||
|
///
|
||||||
|
/// Unlike [XenditCheckoutScreen] this carries no navigation-interception logic
|
||||||
|
/// — it just loads [url] and lets the user read it, with a close button and a
|
||||||
|
/// progress bar. Push it with a plain `Navigator.push(MaterialPageRoute(...))`.
|
||||||
|
class WebPageScreen extends StatefulWidget {
|
||||||
|
final String url;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const WebPageScreen({
|
||||||
|
super.key,
|
||||||
|
required this.url,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WebPageScreen> createState() => _WebPageScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebPageScreenState extends State<WebPageScreen> {
|
||||||
|
late final WebViewController _controller;
|
||||||
|
int _progress = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = WebViewController()
|
||||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..setBackgroundColor(HaloTokens.surface)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onProgress: (p) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _progress = p);
|
||||||
|
},
|
||||||
|
onWebResourceError: (error) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[WebPageScreen] WebResourceError '
|
||||||
|
'code=${error.errorCode} type=${error.errorType} '
|
||||||
|
'desc=${error.description} url=${error.url}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..loadRequest(Uri.parse(widget.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: HaloTokens.surface,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: HaloTokens.surface,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: HaloTokens.brandDark),
|
||||||
|
tooltip: 'Tutup',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
widget.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: HaloTokens.fontDisplay,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: HaloTokens.brandDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
bottom: _progress < 100
|
||||||
|
? PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(2),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: _progress / 100.0,
|
||||||
|
minHeight: 2,
|
||||||
|
backgroundColor: HaloTokens.brandSofter,
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||||
|
HaloTokens.brand,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
body: WebViewWidget(controller: _controller),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/auth/auth_notifier.dart';
|
import '../../core/auth/auth_notifier.dart';
|
||||||
import '../../core/theme/halo_tokens.dart';
|
import '../../core/theme/halo_tokens.dart';
|
||||||
|
import '../../core/widgets/web_page_screen.dart';
|
||||||
import '../home/widgets/halo_tab_bar.dart';
|
import '../home/widgets/halo_tab_bar.dart';
|
||||||
|
|
||||||
/// "Kamu" tab — profile screen.
|
/// "Kamu" tab — profile screen.
|
||||||
@@ -78,7 +79,14 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
_MenuItemData(
|
_MenuItemData(
|
||||||
icon: Icons.lock_outline,
|
icon: Icons.lock_outline,
|
||||||
label: 'kebijakan privasi',
|
label: 'kebijakan privasi',
|
||||||
onTap: () {},
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const WebPageScreen(
|
||||||
|
url: 'https://mybestieindonesia.com/privacy',
|
||||||
|
title: 'kebijakan privasi',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Operational decisions and dependency configuration for staging/production. Keep
|
|||||||
|
|
||||||
| Component | Service | Tier / Notes |
|
| Component | Service | Tier / Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Backend (public + internal) | GCP Cloud Run | Horizontal scaling; SIGTERM trapped for graceful drain ([server.js](../backend/src/server.js)) |
|
| Backend (public + internal) | Self-hosted Docker (VPS / Kubernetes / Docker Engine) | NOT Cloud Run. Container from [backend/Dockerfile](../backend/Dockerfile); horizontal scaling via replicas; SIGTERM trapped for graceful drain ([server.js](../backend/src/server.js)) |
|
||||||
| Database | GCP Cloud SQL (PostgreSQL) | Source of truth for all durable state |
|
| Database | GCP Cloud SQL (PostgreSQL) | Source of truth for all durable state |
|
||||||
| Pub/sub + cache | Valkey | Self-hosted on VM today; Memorystore Standard (HA) recommended for prod (see [§ Valkey](#valkey)) |
|
| Pub/sub + cache | Valkey | Self-hosted on VM today; Memorystore Standard (HA) recommended for prod (see [§ Valkey](#valkey)) |
|
||||||
| Networking | GCP VPC | Internal listener (port 3001) never exposed; CC reaches it via VPN |
|
| Networking | GCP VPC | Internal listener (port 3001) never exposed; CC reaches it via VPN |
|
||||||
@@ -88,7 +88,86 @@ The system is correct on any tier — HA reduces customer-visible latency spikes
|
|||||||
|
|
||||||
## Cloud Run
|
## Cloud Run
|
||||||
|
|
||||||
(Placeholder — fill in as we make decisions about region, min/max instances, concurrency, secrets manager wiring.)
|
(Placeholder for prod tuning — fill in as we make decisions about region, min/max instances, concurrency, secrets manager wiring.)
|
||||||
|
|
||||||
|
### Manual staging deploy runbook
|
||||||
|
|
||||||
|
Goal: stand up a staging backend so the Android **staging** flavor (`com.mybestie.staging`) has a real `API_BASE_URL` to talk to. Done manually for now (no CI/CD yet — see open ops).
|
||||||
|
|
||||||
|
> **Deploy target: self-hosted Docker** (VPS / Kubernetes / Docker Engine) — not Cloud Run. The backend ships a multi-stage [backend/Dockerfile](../backend/Dockerfile) (Node 20, non-root runtime, native `bcrypt` compiled in the build stage). Build with `docker build -t halobestie-backend ./backend`.
|
||||||
|
>
|
||||||
|
> **Full operational runbook — install Docker, build/push, migrate, run (Docker + Compose + k8s), and log mapping/rotation — lives in [backend/DEPLOY.md](../backend/DEPLOY.md).** The steps below are the staging-bring-up summary.
|
||||||
|
|
||||||
|
**A1 — Provision the staging database (Cloud SQL Postgres)**
|
||||||
|
1. Create a Cloud SQL Postgres instance (or a separate `halobestie_staging` DB on a shared instance). Pin the **same region** as the Cloud Run service.
|
||||||
|
2. Capture its connection string for `DATABASE_URL` (use the Cloud SQL connector / Unix socket form for Cloud Run, or private IP over the VPC connector).
|
||||||
|
3. Run migrations + seed against it:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
DATABASE_URL=postgresql://... npm run db:migrate
|
||||||
|
DATABASE_URL=postgresql://... npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
**A2 — Provision staging Valkey** — self-hosted Docker on the VM is fine for staging (`--appendonly yes`, see [§ Valkey](#valkey)). Note the `VALKEY_URL`.
|
||||||
|
|
||||||
|
**A3 — Staging Firebase Admin creds** — the app's staging `google-services.json` / `GoogleService-Info.plist` point at Firebase project **`my-bestie-876ec`**. The backend's `FIREBASE_SERVICE_ACCOUNT` **must be a service-account key from that same project**, or FCM push + token verification will silently target the wrong project. Mount it as a secret and set `FIREBASE_SERVICE_ACCOUNT_PATH` (or switch to a Secret Manager mount).
|
||||||
|
|
||||||
|
**A4 — Build the image + run migrations, then start the container.**
|
||||||
|
|
||||||
|
Build (on a build host or in CI), then push to your registry:
|
||||||
|
```bash
|
||||||
|
docker build -t <registry>/halobestie-backend:staging ./backend
|
||||||
|
docker push <registry>/halobestie-backend:staging
|
||||||
|
```
|
||||||
|
|
||||||
|
Run migrations as a **one-off** before (re)starting the service — never auto-migrate on boot (replica race):
|
||||||
|
```bash
|
||||||
|
docker run --rm --env-file backend/.env.staging \
|
||||||
|
<registry>/halobestie-backend:staging node src/db/migrate.js
|
||||||
|
# first deploy only:
|
||||||
|
docker run --rm --env-file backend/.env.staging \
|
||||||
|
<registry>/halobestie-backend:staging node src/db/seed.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the service (plain Docker Engine example; k8s = Deployment + Service with the same env/secrets and liveness/readiness probes on `:3000`):
|
||||||
|
```bash
|
||||||
|
docker run -d --name halobestie-staging \
|
||||||
|
--env-file backend/.env.staging \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v /path/to/firebase-sa.json:/secrets/firebase-sa.json:ro \
|
||||||
|
--restart unless-stopped \
|
||||||
|
<registry>/halobestie-backend:staging
|
||||||
|
```
|
||||||
|
- Publish **only** port 3000. The internal listener (3001) stays bound to `127.0.0.1` inside the container — do not map it.
|
||||||
|
- `FIREBASE_SERVICE_ACCOUNT_PATH` must point at the mounted path (e.g. `/secrets/firebase-sa.json`), not a baked-in file.
|
||||||
|
- Put a TLS-terminating reverse proxy (Nginx / Traefik / Caddy) in front for `https://staging-api.halobestie.com`.
|
||||||
|
|
||||||
|
Staging-specific env values (`backend/.env.staging`; see [backend/.env.example](../backend/.env.example) for the full list):
|
||||||
|
| Var | Staging value |
|
||||||
|
|---|---|
|
||||||
|
| `AUTH_JWT_SECRET` | a fresh secret — **not** the prod one |
|
||||||
|
| `XENDIT_ENABLED` | `false` until you wire test-mode keys + webhook |
|
||||||
|
| `XENDIT_SECRET_KEY` / `XENDIT_WEBHOOK_TOKEN` | Xendit **test** credentials |
|
||||||
|
| `XENDIT_SUCCESS/FAILURE_REDIRECT_URL` | staging backend's `/payment/return/*` URLs |
|
||||||
|
| `FAZPASS_ENABLED` | `false` (test-user OTP bypass path) unless testing real OTP |
|
||||||
|
| `CC_ORIGIN` | staging control-center origin (if deployed) |
|
||||||
|
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | staging control-center login |
|
||||||
|
|
||||||
|
> **Public listener only.** The internal listener (port 3001, control center) must stay off the public internet — don't expose it from this Cloud Run service. CC for staging, if needed, goes behind the VPC/VPN per the root architecture rules.
|
||||||
|
|
||||||
|
**A5 — Capture the URL.** Point a DNS record (e.g. `staging-api.halobestie.com`) at the host/reverse proxy and terminate TLS there. **This HTTPS URL is the value the app needs** in Phase B.
|
||||||
|
|
||||||
|
### App handoff (Phase B) — once A5 gives a URL
|
||||||
|
1. Put the real URL in [`client_app/env/staging.json`](../client_app/env/staging.json) + [`mitra_app/env/staging.json`](../mitra_app/env/staging.json) (`API_BASE_URL`), and remove the `_TODO` key from the client file.
|
||||||
|
2. Build the staging APK:
|
||||||
|
```bash
|
||||||
|
cd client_app
|
||||||
|
flutter build apk --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||||
|
```
|
||||||
|
Output: `build/app/outputs/flutter-apk/app-staging-release.apk`.
|
||||||
|
3. Distribute via **Firebase App Distribution** (debug-signed APK is accepted — no upload keystore needed for staging) or share the APK directly. `com.mybestie.staging` installs side-by-side with prod.
|
||||||
|
|
||||||
|
> **Release signing is still debug keys** ([client_app/android/app/build.gradle.kts](../client_app/android/app/build.gradle.kts) `release { ... }`). Fine for Firebase App Distribution / direct APK. A real upload keystore is only required if you later publish staging to a Play Store internal-testing track. iOS staging is **not** wired yet (only one `Runner.xcscheme` — no per-flavor schemes/build-configs).
|
||||||
|
|
||||||
## Cloud SQL
|
## Cloud SQL
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user