diff --git a/.env.example b/.env.example
index be6c5af..a8c81ad 100644
--- a/.env.example
+++ b/.env.example
@@ -41,3 +41,11 @@ AI_CONNECT_TIMEOUT_SECONDS=5
AI_TIMEOUT_SECONDS=90
AI_RATE_LIMIT_CAPACITY=5
AI_RATE_LIMIT_REFILL_PER_MINUTE=2
+
+# Monitoring stack (docker-compose.monitoring.yml — optional overlay).
+# Grafana is never public: reach it via SSH tunnel only:
+# ssh -L 3000:localhost:3001 user@vps # then http://localhost:3000
+# Generate a strong password once on the host:
+# echo "GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)" >> .env
+GRAFANA_ADMIN_USER=admin
+GRAFANA_ADMIN_PASSWORD=change_me_strong_password
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..2fa5e36
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,10 @@
+# Force LF line endings for files that run on the Linux VPS, so they
+# work regardless of the contributor's OS / git autocrlf setting.
+# (CRLF in a shell script breaks the shebang: "bad interpreter ^M".)
+*.sh text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+Caddyfile text eol=lf
+Makefile text eol=lf
+Dockerfile text eol=lf
+mvnw text eol=lf
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..d24a4b8
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,49 @@
+version: 2
+
+updates:
+ # ── Backend (Maven) ──────────────────────────────────────
+ - package-ecosystem: maven
+ directory: /apps/backend
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+ labels: [dependencies, backend]
+ groups:
+ backend-minor-patch:
+ update-types: [minor, patch]
+
+ # ── Frontend (npm/pnpm) ──────────────────────────────────
+ - package-ecosystem: npm
+ directory: /apps/frontend
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+ labels: [dependencies, frontend]
+ groups:
+ frontend-minor-patch:
+ update-types: [minor, patch]
+ ignore:
+ # Next.js is an internal fork — never auto-bump (see apps/frontend/AGENTS.md)
+ - dependency-name: next
+
+ # ── Docker base images ───────────────────────────────────
+ - package-ecosystem: docker
+ directory: /apps/backend
+ schedule:
+ interval: weekly
+ labels: [dependencies, docker]
+ - package-ecosystem: docker
+ directory: /apps/frontend
+ schedule:
+ interval: weekly
+ labels: [dependencies, docker]
+
+ # ── GitHub Actions ───────────────────────────────────────
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ labels: [dependencies, github-actions]
+ groups:
+ actions:
+ update-types: [minor, patch]
diff --git a/.github/workflows/build-services.yml b/.github/workflows/build-services.yml
deleted file mode 100644
index f9487f3..0000000
--- a/.github/workflows/build-services.yml
+++ /dev/null
@@ -1,96 +0,0 @@
-name: Build Services CI
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
-
-permissions:
- contents: read
-
-jobs:
- changes:
- name: Detect changed paths
- runs-on: ubuntu-latest
- outputs:
- frontend: ${{ steps.filter.outputs.frontend }}
- backend: ${{ steps.filter.outputs.backend }}
- steps:
- - uses: actions/checkout@v4
- - uses: dorny/paths-filter@v3
- id: filter
- with:
- filters: |
- frontend:
- - 'apps/frontend/**'
- backend:
- - 'apps/backend/**'
-
- frontend:
- name: Frontend — Build
- needs: changes
- if: needs.changes.outputs.frontend == 'true'
- runs-on: ubuntu-latest
- defaults:
- run:
- working-directory: apps/frontend
- steps:
- - uses: actions/checkout@v4
-
- - uses: pnpm/action-setup@v4
- with:
- version: '9.15.0'
-
- - uses: actions/setup-node@v4
- with:
- node-version: '20'
- cache: pnpm
- cache-dependency-path: apps/frontend/pnpm-lock.yaml
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Build
- run: pnpm build
-
- backend:
- name: Backend — Build
- needs: changes
- if: needs.changes.outputs.backend == 'true'
- runs-on: ubuntu-latest
- defaults:
- run:
- working-directory: apps/backend
- steps:
- - uses: actions/checkout@v4
-
- - uses: actions/setup-java@v4
- with:
- java-version: '17'
- distribution: temurin
- cache: maven
-
- - name: Make mvnw executable
- run: chmod +x ./mvnw
-
- - name: Build
- run: ./mvnw package -DskipTests -B
-
- docker:
- name: Docker — Build images
- runs-on: ubuntu-latest
- needs: [frontend, backend]
- if: |
- always() &&
- !contains(needs.*.result, 'failure') &&
- !contains(needs.*.result, 'cancelled') &&
- (needs.frontend.result == 'success' || needs.backend.result == 'success')
- steps:
- - uses: actions/checkout@v4
-
- - name: Create env file
- run: cp .env.example .env
-
- - name: Build images
- run: docker compose build
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..0a7de5d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,217 @@
+name: CI
+
+# Fast feedback on every push / PR to dev and main.
+# Heavy security scanning lives in security.yml (main + nightly).
+on:
+ push:
+ branches: [dev, main]
+ pull_request:
+ branches: [dev, main]
+
+# Cancel superseded runs on the same ref.
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ changes:
+ name: Detect changed paths
+ runs-on: ubuntu-latest
+ outputs:
+ frontend: ${{ steps.filter.outputs.frontend }}
+ backend: ${{ steps.filter.outputs.backend }}
+ migrations: ${{ steps.filter.outputs.migrations }}
+ i18n: ${{ steps.filter.outputs.i18n }}
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
+ id: filter
+ with:
+ filters: |
+ frontend:
+ - 'apps/frontend/**'
+ backend:
+ - 'apps/backend/**'
+ migrations:
+ - 'apps/backend/src/main/resources/db/migration/**'
+ i18n:
+ - 'apps/frontend/messages/**'
+
+ frontend:
+ name: Frontend — lint, typecheck, build
+ needs: changes
+ if: needs.changes.outputs.frontend == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ defaults:
+ run:
+ working-directory: apps/frontend
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+ with:
+ version: '9.15.0'
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '20'
+ cache: pnpm
+ cache-dependency-path: apps/frontend/pnpm-lock.yaml
+ - name: Install
+ run: pnpm install --frozen-lockfile
+ - name: Lint
+ run: pnpm lint
+ - name: Type-check
+ run: pnpm typecheck
+ - name: Build
+ run: pnpm build
+
+ backend:
+ name: Backend — compile, test, coverage
+ needs: changes
+ if: needs.changes.outputs.backend == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ defaults:
+ run:
+ working-directory: apps/backend
+ # Ephemeral Postgres for @SpringBootTest (context load runs Flyway against it).
+ # Created before the steps, reachable at localhost:5432, destroyed with the runner.
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: codestar
+ POSTGRES_PASSWORD: codestar
+ POSTGRES_DB: codestardb
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U codestar -d codestardb"
+ --health-interval 10s --health-timeout 5s --health-retries 5
+ env:
+ DB_URL: jdbc:postgresql://localhost:5432/codestardb
+ DB_USER: codestar
+ DB_PASSWORD: codestar
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ java-version: '17'
+ distribution: temurin
+ cache: maven
+ - name: Make mvnw executable
+ run: chmod +x ./mvnw
+ - name: Compile
+ run: ./mvnw compile -B --no-transfer-progress
+ - name: Test + coverage
+ run: ./mvnw verify -B --no-transfer-progress
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: jacoco-coverage
+ path: apps/backend/target/site/jacoco/
+ retention-days: 7
+ if-no-files-found: warn
+ compression-level: 6
+
+ governance:
+ name: Codestar governance (migrations, i18n)
+ needs: changes
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ fetch-depth: 0 # full history so guards can diff against the base branch
+ - name: Flyway migration governance
+ if: needs.changes.outputs.migrations == 'true'
+ env:
+ BASE_REF: origin/${{ github.base_ref || 'main' }}
+ run: bash scripts/ci/flyway-governance.sh
+ - name: i18n key parity
+ if: needs.changes.outputs.i18n == 'true'
+ run: bash scripts/ci/i18n-parity.sh
+
+ secrets:
+ name: Secret scan (gitleaks)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ fetch-depth: 0
+ # Run the gitleaks binary directly: the GitHub Action requires a paid
+ # license for organisation repos, the open-source binary does not.
+ - name: Run gitleaks
+ env:
+ GITLEAKS_VERSION: 8.21.2
+ run: |
+ curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
+ | tar -xz gitleaks
+ ./gitleaks detect --source . --redact --verbose --exit-code 1
+
+ dependency-review:
+ name: Dependency review
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: actions/dependency-review-action@e58c696e52cac8e62d61cc21fda89565d71505d7 # v4.3.0
+ with:
+ fail-on-severity: high
+ comment-summary-in-pr: on-failure
+
+ docker:
+ name: Docker — compose build
+ needs: [frontend, backend]
+ if: |
+ always() &&
+ !contains(needs.*.result, 'failure') &&
+ !contains(needs.*.result, 'cancelled') &&
+ (needs.frontend.result == 'success' || needs.backend.result == 'success')
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Create env file
+ run: cp .env.example .env
+ - name: Build images
+ run: docker compose build
+
+ # Single required status check for branch protection.
+ ci-required:
+ name: CI required
+ needs: [frontend, backend, governance, secrets, docker]
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - name: Verify no required job failed
+ run: |
+ results='${{ join(needs.*.result, ',') }}'
+ echo "Upstream results: $results"
+ case "$results" in
+ *failure*|*cancelled*) echo "A required job failed."; exit 1 ;;
+ *) echo "All required jobs passed (or were skipped)." ;;
+ esac
+
+ # Discord notification. No-ops when DISCORD_WEBHOOK_URL is unset (fork-safe).
+ notify:
+ name: Discord notification
+ needs: [frontend, backend, governance, secrets, docker]
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1
+ with:
+ webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ status: ${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }}
+ title: "CI pipeline"
+ username: Codestar CI
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..77bbc32
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,61 @@
+name: Deploy
+
+# Continuous deployment over SSH (build-on-VPS model): connect to the
+# server and run ./update.sh, which pulls, rebuilds, health-checks and
+# rolls back on failure.
+#
+# GUARDED: each job runs only if its host secret is set, so a fork without
+# deployment secrets simply skips this workflow (no red runs).
+#
+# Required secrets (per environment):
+# prod : SSH_HOST, SSH_USER, SSH_KEY (+ optional SSH_PORT, SSH_PATH)
+# staging : SSH_HOST_STAGING, SSH_USER, SSH_KEY (+ optional SSH_PORT, SSH_PATH)
+# SSH_PATH defaults to ~/codestar.
+on:
+ push:
+ branches: [dev, main]
+ workflow_dispatch:
+
+concurrency:
+ group: deploy-${{ github.ref }}
+ cancel-in-progress: false # never interrupt a deployment mid-flight
+
+permissions:
+ contents: read
+
+jobs:
+ deploy-prod:
+ name: Deploy → production
+ if: github.ref == 'refs/heads/main' && vars.HAS_PROD == 'true'
+ runs-on: ubuntu-latest
+ environment: production
+ timeout-minutes: 30
+ steps:
+ - name: Run remote update
+ uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
+ with:
+ host: ${{ secrets.SSH_HOST }}
+ username: ${{ secrets.SSH_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ port: ${{ secrets.SSH_PORT || 22 }}
+ script: |
+ cd ${{ secrets.SSH_PATH || '~/codestar' }}
+ ./update.sh --yes
+
+ deploy-staging:
+ name: Deploy → staging
+ if: github.ref == 'refs/heads/dev' && vars.HAS_STAGING == 'true'
+ runs-on: ubuntu-latest
+ environment: staging
+ timeout-minutes: 30
+ steps:
+ - name: Run remote update
+ uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
+ with:
+ host: ${{ secrets.SSH_HOST_STAGING }}
+ username: ${{ secrets.SSH_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ port: ${{ secrets.SSH_PORT || 22 }}
+ script: |
+ cd ${{ secrets.SSH_PATH || '~/codestar' }}
+ ./update.sh --yes
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 0000000..49e8569
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,146 @@
+name: Security
+
+# Heavy security scanning: runs on main, nightly, and on demand.
+# Fast PR feedback (gitleaks, dependency-review) lives in ci.yml.
+on:
+ push:
+ branches: [main]
+ schedule:
+ - cron: '27 3 * * *' # nightly at 03:27 UTC
+ workflow_dispatch:
+
+concurrency:
+ group: security-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ codeql-java:
+ name: CodeQL — Java
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ security-events: write
+ actions: read
+ contents: read
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ java-version: '17'
+ distribution: temurin
+ cache: maven
+ - uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
+ with:
+ languages: java-kotlin
+ build-mode: autobuild
+ - uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
+ with:
+ category: /language:java-kotlin
+
+ codeql-js:
+ name: CodeQL — JavaScript/TypeScript
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ security-events: write
+ actions: read
+ contents: read
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
+ with:
+ languages: javascript-typescript
+ build-mode: none
+ - uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
+ with:
+ category: /language:javascript-typescript
+
+ trivy:
+ name: Trivy — filesystem & config
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ security-events: write
+ contents: read
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - name: Trivy filesystem scan (dependencies)
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
+ with:
+ scan-type: fs
+ scan-ref: .
+ format: sarif
+ output: trivy-fs.sarif
+ severity: CRITICAL,HIGH
+ - name: Upload filesystem SARIF
+ uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
+ if: always()
+ with:
+ sarif_file: trivy-fs.sarif
+ category: trivy-fs
+
+ - name: Trivy config scan (Dockerfiles, compose)
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
+ with:
+ scan-type: config
+ scan-ref: .
+ format: sarif
+ output: trivy-config.sarif
+ severity: CRITICAL,HIGH
+ - name: Upload config SARIF
+ uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
+ if: always()
+ with:
+ sarif_file: trivy-config.sarif
+ category: trivy-config
+
+ scorecard:
+ name: OpenSSF Scorecard
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ security-events: write
+ id-token: write
+ contents: read
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ persist-credentials: false
+ - uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
+ with:
+ results_file: scorecard.sarif
+ results_format: sarif
+ publish_results: true
+ - uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
+ if: always()
+ with:
+ sarif_file: scorecard.sarif
+ category: scorecard
+
+ sbom:
+ name: SBOM + provenance attestation
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ id-token: write # required for keyless signing of the attestation
+ attestations: write # required to record the attestation
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Generate SBOM (CycloneDX)
+ uses: anchore/sbom-action@55dc4ee22412511ee8c3142cbea40418e6cec693 # v0.17.8
+ with:
+ path: .
+ format: cyclonedx-json
+ output-file: sbom.cyclonedx.json
+ artifact-name: sbom.cyclonedx.json
+ - name: Attest build provenance for the SBOM
+ # Only on direct pushes to this repo (forks/PRs cannot write attestations).
+ if: github.event_name != 'pull_request'
+ uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
+ with:
+ subject-path: sbom.cyclonedx.json
diff --git a/.gitignore b/.gitignore
index d39aa33..7d68508 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,13 @@
*.key
*.crt
+# Per-instance branding/config (never committed — survives git pull)
+/config/*
+!/config/.gitkeep
+
+# Database backups produced by update.sh (stay on the host only)
+/backups/
+
# OS
.DS_Store
.AppleDouble
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..13bdcf1
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,70 @@
+# GitLab CI mirror of .github/workflows/ci.yml.
+# Same logic, driven by the shared scripts in scripts/ci/ and the same
+# build commands, so a fork hosted on GitLab gets equivalent coverage.
+#
+# Native GitLab security templates provide SAST / secret detection /
+# dependency scanning (the GitLab equivalents of CodeQL / gitleaks / Trivy).
+include:
+ - template: Security/SAST.gitlab-ci.yml
+ - template: Security/Secret-Detection.gitlab-ci.yml
+ - template: Security/Dependency-Scanning.gitlab-ci.yml
+
+stages: [build, test, security]
+
+default:
+ interruptible: true
+
+# ── Frontend ─────────────────────────────────────────────────
+frontend:
+ stage: build
+ image: node:20-alpine
+ rules:
+ - changes: [apps/frontend/**/*]
+ cache:
+ key:
+ files: [apps/frontend/pnpm-lock.yaml]
+ paths: [apps/frontend/.pnpm-store]
+ before_script:
+ - corepack enable && corepack prepare pnpm@9.15.0 --activate
+ - cd apps/frontend
+ - pnpm config set store-dir .pnpm-store
+ - pnpm install --frozen-lockfile
+ script:
+ - pnpm lint
+ - pnpm typecheck
+ - pnpm build
+
+# ── Backend ──────────────────────────────────────────────────
+backend:
+ stage: build
+ image: maven:3.9-eclipse-temurin-17
+ rules:
+ - changes: [apps/backend/**/*]
+ cache:
+ key: maven
+ paths: [.m2/repository]
+ variables:
+ MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
+ script:
+ - cd apps/backend
+ - ./mvnw verify -B --no-transfer-progress
+ artifacts:
+ when: always
+ paths: [apps/backend/target/site/jacoco/]
+ expire_in: 7 days
+
+# ── Codestar governance guards ───────────────────────────────
+governance:
+ stage: test
+ image: alpine:3.20
+ before_script:
+ - apk add --no-cache bash git jq
+ script:
+ - |
+ if git diff --name-only "origin/$CI_DEFAULT_BRANCH"...HEAD -- apps/backend/src/main/resources/db/migration | grep -q .; then
+ BASE_REF="origin/$CI_DEFAULT_BRANCH" bash scripts/ci/flyway-governance.sh
+ fi
+ - |
+ if git diff --name-only "origin/$CI_DEFAULT_BRANCH"...HEAD -- apps/frontend/messages | grep -q .; then
+ bash scripts/ci/i18n-parity.sh
+ fi
diff --git a/Caddyfile b/Caddyfile
new file mode 100644
index 0000000..1393717
--- /dev/null
+++ b/Caddyfile
@@ -0,0 +1,41 @@
+# ─────────────────────────────────────────────────────────────
+# Caddy reverse proxy — automatic HTTPS (Let's Encrypt).
+# The domain is injected at runtime from the DOMAIN env var, so this
+# file stays generic: every deployment sets its own DOMAIN in .env.
+# Only the frontend is exposed; backend & postgres stay on the
+# internal Docker network and are never reachable from the Internet.
+# ─────────────────────────────────────────────────────────────
+
+{
+ # Admin API on the internal Docker network so the monitoring stack can
+ # scrape Caddy's own metrics. This port is never published to the host,
+ # so it stays unreachable from the Internet.
+ admin 0.0.0.0:2019
+ servers {
+ metrics
+ }
+}
+
+{$DOMAIN} {
+ # Compress responses (zstd preferred, gzip fallback).
+ encode zstd gzip
+
+ # All public traffic goes to the Next.js frontend over the internal
+ # Docker network. The frontend talks to the backend server-side.
+ reverse_proxy frontend:3000
+
+ # Structured access logs to stdout — ready to be shipped to Loki later.
+ log {
+ output stdout
+ format json
+ }
+
+ # Baseline security headers.
+ header {
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
+ X-Content-Type-Options "nosniff"
+ X-Frame-Options "SAMEORIGIN"
+ Referrer-Policy "strict-origin-when-cross-origin"
+ -Server
+ }
+}
diff --git a/Makefile b/Makefile
index a11b3ba..dfd5204 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: setup prod dev down logs ps clean
+.PHONY: setup prod dev down logs ps clean monitoring
## First-time setup: copy env template
setup:
@@ -27,3 +27,7 @@ logs:
## Service status
ps:
docker compose ps
+
+## Monitoring stack: Prometheus + Grafana + Loki (reach Grafana via SSH tunnel)
+monitoring:
+ docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.monitoring.yml up -d
diff --git a/README.md b/README.md
index 19530d7..016c733 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,10 @@
# Codestar
+[](https://github.com/CodeStar-Project/codestar/actions/workflows/ci.yml)
+[](https://github.com/CodeStar-Project/codestar/actions/workflows/security.yml)
+[](https://scorecard.dev/viewer/?uri=github.com/CodeStar-Project/codestar)
+[](LICENSE)
+
Open-source & self-hosted e-learning platform template to build yours easly.
## Backend
@@ -76,3 +81,31 @@ Or with frontend hot-reload via Docker:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
```
+
+## Monitoring (optional)
+
+An overlay adds Prometheus, Grafana, Loki, Promtail, node-exporter and cAdvisor.
+Every container stays on the internal network — no public port is opened.
+
+**1. Generate the Grafana admin password (one-time, on the host)**
+
+```bash
+echo "GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)" >> .env
+```
+
+**2. Start the stack**
+
+```bash
+make monitoring
+# equivalent to:
+# docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.monitoring.yml up -d
+```
+
+**3. Reach Grafana through an SSH tunnel** (it is never exposed publicly)
+
+```bash
+ssh -L 3000:localhost:3001 user@vps # then open http://localhost:3000
+```
+
+Log in with `admin` and the password you generated. Dashboards are provisioned
+automatically: Spring/JVM, VPS system, containers, and Loki logs.
diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml
index 4056386..9fe7243 100644
--- a/apps/backend/pom.xml
+++ b/apps/backend/pom.xml
@@ -100,6 +100,20 @@
2.7.0
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+ runtime
+
+
+
+
+ net.logstash.logback
+ logstash-logback-encoder
+ 8.0
+
+
@@ -108,6 +122,24 @@
org.springframework.boot
spring-boot-maven-plugin
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.12
+
+
+ prepare-agent
+ prepare-agent
+
+
+ report
+ verify
+ report
+
+
+
diff --git a/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java b/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java
index 72d8e82..fb9c990 100644
--- a/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java
+++ b/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java
@@ -49,10 +49,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/api/v1/settings/branding").permitAll()
.requestMatchers(HttpMethod.GET,
"/api/v1/media/**").permitAll()
- // actuator health
+ // actuator: health probes + info + Prometheus scrape.
+ // Not internet-facing (backend runs on loopback + internal network).
.requestMatchers(HttpMethod.GET,
"/actuator/health",
- "/actuator/health/**").permitAll()
+ "/actuator/health/**",
+ "/actuator/info",
+ "/actuator/prometheus").permitAll()
// swagger / OpenAPI
.requestMatchers(
"/v3/api-docs/**",
diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties
index 2e01617..0c5e17a 100644
--- a/apps/backend/src/main/resources/application.properties
+++ b/apps/backend/src/main/resources/application.properties
@@ -12,9 +12,15 @@ spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.open-in-view=false
-# Actuator — expose the health endpoint. Aggregate status includes DB connectivity
-management.endpoints.web.exposure.include=health
+# Actuator — health (DB connectivity), info, and Prometheus metrics.
+# The backend is never public (loopback + internal Docker network), so the
+# Prometheus scrape endpoint is only reachable by the monitoring stack.
+management.endpoints.web.exposure.include=health,info,prometheus
management.endpoint.health.show-details=never
+# Kubernetes-style liveness/readiness probes (used by Docker healthcheck & CD)
+management.endpoint.health.probes.enabled=true
+management.health.livenessstate.enabled=true
+management.health.readinessstate.enabled=true
# Flyway
spring.flyway.enabled=true
diff --git a/apps/backend/src/main/resources/logback-spring.xml b/apps/backend/src/main/resources/logback-spring.xml
new file mode 100644
index 0000000..82c5ce1
--- /dev/null
+++ b/apps/backend/src/main/resources/logback-spring.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ traceId
+ spanId
+ {"service":"codestar-backend"}
+
+
+
+
+
+
+
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
index e7ea010..e68ddd4 100644
--- a/apps/frontend/package.json
+++ b/apps/frontend/package.json
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit"
},
"engines": {
"node": ">=20.9.0"
diff --git a/deploy/systemd/codestar-update.service b/deploy/systemd/codestar-update.service
new file mode 100644
index 0000000..7b2c016
--- /dev/null
+++ b/deploy/systemd/codestar-update.service
@@ -0,0 +1,22 @@
+# Codestar — auto-update unit (oneshot, triggered by codestar-update.timer).
+#
+# Install (edit User and the repo path to match your setup):
+# sudo cp deploy/systemd/codestar-update.* /etc/systemd/system/
+# sudo systemctl daemon-reload
+# sudo systemctl enable --now codestar-update.timer
+#
+# Inspect:
+# systemctl list-timers codestar-update.timer
+# journalctl -u codestar-update.service -f
+[Unit]
+Description=Codestar instance auto-update
+Wants=network-online.target
+After=network-online.target docker.service
+
+[Service]
+Type=oneshot
+User=ray
+WorkingDirectory=/home/ray/codestar
+ExecStart=/home/ray/codestar/update.sh --yes
+# Don't let a deploy hang forever.
+TimeoutStartSec=1800
diff --git a/deploy/systemd/codestar-update.timer b/deploy/systemd/codestar-update.timer
new file mode 100644
index 0000000..2cba7fc
--- /dev/null
+++ b/deploy/systemd/codestar-update.timer
@@ -0,0 +1,15 @@
+# Codestar — schedule for the auto-update service.
+# Default: every 15 minutes. Adjust OnCalendar to taste, e.g. "*-*-* 03:00:00"
+# for a nightly update at 3am.
+[Unit]
+Description=Run Codestar auto-update on a schedule
+
+[Timer]
+OnCalendar=*:0/15
+# Run a missed update if the VPS was off at the scheduled time.
+Persistent=true
+# Avoid all instances hitting GitHub at the same second.
+RandomizedDelaySec=120
+
+[Install]
+WantedBy=timers.target
diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml
new file mode 100644
index 0000000..443d8f9
--- /dev/null
+++ b/docker-compose.monitoring.yml
@@ -0,0 +1,125 @@
+# ─────────────────────────────────────────────────────────────
+# Monitoring overlay — Prometheus + Grafana + Loki stack.
+#
+# Usage (always combined with the base, prod optional):
+# docker compose \
+# -f docker-compose.yml \
+# -f docker-compose.prod.yml \
+# -f docker-compose.monitoring.yml up -d
+#
+# The app runs perfectly WITHOUT this file — monitoring is opt-in.
+#
+# Security model (same as the rest of the stack):
+# • No service is published to the Internet.
+# • Grafana is bound to 127.0.0.1 only → reach it through an SSH tunnel:
+# ssh -L 3000:localhost:3001 user@vps # then open http://localhost:3000
+# • All collectors live on the internal "codestar-net" network and scrape
+# the app containers by their service/container names.
+# ─────────────────────────────────────────────────────────────
+services:
+
+ # ── Metrics store ──────────────────────────────────────────
+ prometheus:
+ image: prom/prometheus:v2.53.0
+ container_name: codestar-prometheus
+ restart: unless-stopped
+ command:
+ - "--config.file=/etc/prometheus/prometheus.yml"
+ - "--storage.tsdb.path=/prometheus"
+ - "--storage.tsdb.retention.time=15d" # retention decided: 15 days
+ - "--web.enable-lifecycle"
+ volumes:
+ - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - prometheus_data:/prometheus
+ networks:
+ - codestar-net
+
+ # ── Logs store ─────────────────────────────────────────────
+ loki:
+ image: grafana/loki:3.1.0
+ container_name: codestar-loki
+ restart: unless-stopped
+ command: "-config.file=/etc/loki/loki-config.yml"
+ volumes:
+ - ./monitoring/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
+ - loki_data:/loki
+ networks:
+ - codestar-net
+
+ # ── Log collector (containers' stdout → Loki) ──────────────
+ promtail:
+ image: grafana/promtail:3.1.0
+ container_name: codestar-promtail
+ restart: unless-stopped
+ depends_on:
+ - loki
+ command: "-config.file=/etc/promtail/promtail-config.yml"
+ volumes:
+ - ./monitoring/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
+ # Container logs + Docker socket for service discovery & labels.
+ - /var/lib/docker/containers:/var/lib/docker/containers:ro
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - promtail_positions:/tmp
+ networks:
+ - codestar-net
+
+ # ── Host metrics (CPU / RAM / disk of the VPS) ─────────────
+ node-exporter:
+ image: prom/node-exporter:v1.8.1
+ container_name: codestar-node-exporter
+ restart: unless-stopped
+ command:
+ - "--path.rootfs=/host"
+ pid: host
+ volumes:
+ - /:/host:ro,rslave
+ networks:
+ - codestar-net
+
+ # ── Per-container metrics (CPU / RAM / net per service) ────
+ cadvisor:
+ image: gcr.io/cadvisor/cadvisor:v0.49.1
+ container_name: codestar-cadvisor
+ restart: unless-stopped
+ privileged: true
+ devices:
+ - /dev/kmsg
+ volumes:
+ - /:/rootfs:ro
+ - /var/run:/var/run:ro
+ - /sys:/sys:ro
+ - /var/lib/docker/:/var/lib/docker:ro
+ - /dev/disk/:/dev/disk:ro
+ networks:
+ - codestar-net
+
+ # ── Dashboards (SSH-tunnel access only) ────────────────────
+ grafana:
+ image: grafana/grafana:11.1.0
+ container_name: codestar-grafana
+ restart: unless-stopped
+ depends_on:
+ - prometheus
+ - loki
+ environment:
+ GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
+ GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD in .env}
+ # Never exposed publicly → sign-up off, anonymous off.
+ GF_USERS_ALLOW_SIGN_UP: "false"
+ GF_AUTH_ANONYMOUS_ENABLED: "false"
+ volumes:
+ - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
+ - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
+ - grafana_data:/var/lib/grafana
+ ports:
+ # Loopback only. Tunnel in: ssh -L 3000:localhost:3001 user@vps
+ # (host port 3001 because the frontend already owns 3000).
+ - "127.0.0.1:3001:3000"
+ networks:
+ - codestar-net
+
+volumes:
+ prometheus_data:
+ loki_data:
+ grafana_data:
+ promtail_positions:
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..a3dd031
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,33 @@
+# ─────────────────────────────────────────────────────────────
+# Production overlay — adds the Caddy reverse proxy (automatic HTTPS).
+#
+# Usage:
+# docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d
+#
+# In the base file, backend (8080) and frontend (3000) are bound to
+# 127.0.0.1 only, so they are NOT public. Caddy reaches the frontend
+# through the internal "codestar-net" network (service name "frontend").
+# ─────────────────────────────────────────────────────────────
+services:
+ caddy:
+ image: caddy:2-alpine
+ container_name: codestar-caddy
+ restart: unless-stopped
+ depends_on:
+ - frontend
+ ports:
+ - "80:80"
+ - "443:443"
+ - "443:443/udp" # HTTP/3 (QUIC)
+ environment:
+ DOMAIN: ${DOMAIN}
+ volumes:
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
+ - caddy_data:/data # certificates & ACME account (persisted)
+ - caddy_config:/config
+ networks:
+ - codestar-net
+
+volumes:
+ caddy_data:
+ caddy_config:
diff --git a/docker-compose.yml b/docker-compose.yml
index 26deb9b..18ea01d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -48,7 +48,9 @@ services:
# uploaded course images (persisted across restarts)
- media_data:/app/media
ports:
- - "${BACKEND_PORT:-8080}:8080"
+ # Bound to loopback only: never public. In prod, Caddy reaches the
+ # backend indirectly via the frontend over the internal network.
+ - "127.0.0.1:${BACKEND_PORT:-8080}:8080"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"]
interval: 10s
@@ -77,7 +79,9 @@ services:
JWT_SECRET: ${JWT_SECRET}
SITE_URL: ${SITE_URL:-http://localhost:3000}
ports:
- - "${FRONTEND_PORT:-3000}:3000"
+ # Bound to loopback only: never public. Caddy (prod overlay) proxies
+ # to this service over the internal "codestar-net" network.
+ - "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
networks:
- codestar-net
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..5cac366
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,177 @@
+#!/usr/bin/env bash
+# ─────────────────────────────────────────────────────────────
+# Codestar — VPS bootstrap installer (day 0, run once).
+#
+# What it does:
+# 1. Installs Docker + compose plugin (if missing)
+# 2. Hardens the host (ufw firewall + fail2ban)
+# 3. Creates .env (random secrets + your answers) — never overwrites
+# an existing .env, so your secrets and customization are safe
+# 4. Builds and starts the full stack behind Caddy (automatic HTTPS)
+# 5. Waits for health and prints the final URL
+#
+# Interactive:
+# sudo ./install.sh
+#
+# Non-interactive (CI / repeatable):
+# sudo DOMAIN=codestar.example.com \
+# ADMIN_EMAIL=you@example.com \
+# ADMIN_PASSWORD='a-strong-password' \
+# ADMIN_NAME='Admin' \
+# SIGNUP_OPEN=false \
+# ./install.sh
+#
+# Re-running is safe: an existing .env is reused as-is; the stack is
+# rebuilt and restarted (your data lives in Docker volumes, untouched).
+# ─────────────────────────────────────────────────────────────
+set -euo pipefail
+
+# ── pretty logging ───────────────────────────────────────────
+c_blue=$'\033[1;34m'; c_green=$'\033[1;32m'; c_yellow=$'\033[1;33m'; c_red=$'\033[1;31m'; c_reset=$'\033[0m'
+log() { printf '%s==>%s %s\n' "$c_blue" "$c_reset" "$*"; }
+ok() { printf '%s✓%s %s\n' "$c_green" "$c_reset" "$*"; }
+warn() { printf '%s!%s %s\n' "$c_yellow" "$c_reset" "$*"; }
+err() { printf '%s✗%s %s\n' "$c_red" "$c_reset" "$*" >&2; }
+
+# Run from the repo root (directory of this script).
+cd "$(dirname "$0")"
+
+# ── privilege handling ───────────────────────────────────────
+if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi
+if [ -n "$SUDO" ] && ! command -v sudo >/dev/null 2>&1; then
+ err "Please run as root (sudo not available)."; exit 1
+fi
+
+# ── helpers ──────────────────────────────────────────────────
+# ask VARNAME "prompt" [secret] [default]
+# Uses an existing environment value if set; otherwise prompts (TTY required).
+ask() {
+ local __var="$1" __prompt="$2" __secret="${3:-}" __default="${4:-}"
+ if [ -n "${!__var:-}" ]; then return 0; fi
+ if [ ! -t 0 ]; then
+ if [ -n "$__default" ]; then printf -v "$__var" '%s' "$__default"; return 0; fi
+ err "Missing required value '$__var' and no TTY to prompt."; exit 1
+ fi
+ if [ "$__secret" = "secret" ]; then
+ read -rsp "$__prompt: " "$__var"; echo
+ else
+ local __hint=""; [ -n "$__default" ] && __hint=" [$__default]"
+ read -rp "$__prompt$__hint: " "$__var"
+ if [ -z "${!__var}" ] && [ -n "$__default" ]; then printf -v "$__var" '%s' "$__default"; fi
+ fi
+ return 0
+}
+
+# set_env KEY VALUE [file] — replace or append KEY=VALUE (literal, no sed pitfalls)
+set_env() {
+ local key="$1" val="$2" file="${3:-.env}"
+ if grep -qE "^${key}=" "$file" 2>/dev/null; then
+ grep -vE "^${key}=" "$file" > "$file.tmp" && mv "$file.tmp" "$file"
+ fi
+ printf '%s=%s\n' "$key" "$val" >> "$file"
+}
+
+rand_hex() { openssl rand -hex "$1"; }
+
+# wait_healthy CONTAINER [tries]
+wait_healthy() {
+ local name="$1" tries="${2:-60}" status
+ for _ in $(seq 1 "$tries"); do
+ status="$(docker inspect -f '{{.State.Health.Status}}' "$name" 2>/dev/null || echo missing)"
+ [ "$status" = "healthy" ] && return 0
+ [ "$status" = "missing" ] && { sleep 5; continue; }
+ printf ' backend health: %s\n' "$status"
+ sleep 5
+ done
+ return 1
+}
+
+# ── 1. Docker ────────────────────────────────────────────────
+log "Checking Docker…"
+if ! command -v docker >/dev/null 2>&1; then
+ log "Docker not found — installing via get.docker.com"
+ curl -fsSL https://get.docker.com | $SUDO sh
+ ok "Docker installed"
+else
+ ok "Docker present: $(docker --version)"
+fi
+if ! docker compose version >/dev/null 2>&1; then
+ err "Docker Compose v2 plugin missing. Install 'docker-compose-plugin' and re-run."; exit 1
+fi
+
+# ── 2. Host hardening (firewall + fail2ban) ──────────────────
+log "Hardening host (ufw + fail2ban)…"
+if command -v apt-get >/dev/null 2>&1; then
+ $SUDO apt-get update -qq
+ $SUDO apt-get install -y -qq ufw fail2ban openssl >/dev/null
+ $SUDO ufw allow 22/tcp >/dev/null 2>&1 || true
+ $SUDO ufw allow 80/tcp >/dev/null 2>&1 || true
+ $SUDO ufw allow 443/tcp >/dev/null 2>&1 || true
+ $SUDO ufw --force enable >/dev/null 2>&1 || true
+ $SUDO systemctl enable --now fail2ban >/dev/null 2>&1 || true
+ ok "Firewall (22/80/443) + fail2ban active"
+else
+ warn "Non-apt system: skipping ufw/fail2ban (configure your firewall manually)."
+fi
+
+# ── 3. .env ──────────────────────────────────────────────────
+if [ -f .env ]; then
+ ok "Existing .env found — reusing it (no secrets overwritten)."
+ DOMAIN="$(grep -E '^DOMAIN=' .env | head -1 | cut -d= -f2- || true)"
+ [ -z "${DOMAIN:-}" ] && { err "Existing .env has no DOMAIN= line. Add it and re-run."; exit 1; }
+else
+ log "Creating .env…"
+ [ -f .env.example ] || { err ".env.example missing — are you in the repo root?"; exit 1; }
+
+ ask DOMAIN "Domain name (e.g. codestar.example.com)"
+ ask ADMIN_EMAIL "Super-admin email"
+ ask ADMIN_PASSWORD "Super-admin password" secret
+ ask ADMIN_NAME "Super-admin display name" "" "Admin"
+ ask SIGNUP_OPEN "Open signups without invitation? (true/false)" "" "false"
+
+ # Create .env only after all answers are collected (no half-written file).
+ cp .env.example .env
+
+ set_env DB_USER "codestar"
+ set_env DB_NAME "codestardb"
+ set_env DB_PASSWORD "$(rand_hex 24)"
+ set_env JWT_SECRET "$(rand_hex 48)" # 96 hex chars (>= 64 required)
+ set_env SITE_URL "https://${DOMAIN}"
+ set_env DOMAIN "${DOMAIN}"
+ set_env SIGNUP_OPEN "${SIGNUP_OPEN}"
+ set_env CODESTAR_BOOTSTRAP_SUPER_ADMIN_EMAIL "${ADMIN_EMAIL}"
+ set_env CODESTAR_BOOTSTRAP_SUPER_ADMIN_PASSWORD "${ADMIN_PASSWORD}"
+ set_env CODESTAR_BOOTSTRAP_SUPER_ADMIN_DISPLAY_NAME "${ADMIN_NAME}"
+
+ ok ".env created (random DB_PASSWORD & JWT_SECRET generated)"
+fi
+
+# ── 4. Build & start ─────────────────────────────────────────
+log "Building and starting the stack (this can take a few minutes)…"
+$SUDO docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d
+
+# ── 5. Wait for health ───────────────────────────────────────
+log "Waiting for the backend to become healthy…"
+if wait_healthy codestar-backend 60; then
+ ok "Backend healthy"
+else
+ warn "Backend not healthy yet. Check logs: docker compose logs -f backend"
+fi
+
+cat <}
+
+ First-time HTTPS note:
+ Caddy fetches a Let's Encrypt certificate on first request.
+ It only works once your DNS A record points ${DOMAIN} -> this server,
+ and ports 80/443 are open. First load may take ~30s.
+
+ Useful commands:
+ docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f
+ docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
+${c_green}────────────────────────────────────────────────${c_reset}
+EOF
diff --git a/monitoring/grafana/dashboards/containers.json b/monitoring/grafana/dashboards/containers.json
new file mode 100644
index 0000000..ec479fb
--- /dev/null
+++ b/monitoring/grafana/dashboards/containers.json
@@ -0,0 +1,48 @@
+{
+ "uid": "codestar-containers",
+ "title": "Codestar — Containers",
+ "tags": ["codestar", "docker"],
+ "timezone": "browser",
+ "schemaVersion": 39,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-6h", "to": "now" },
+ "templating": { "list": [] },
+ "panels": [
+ {
+ "id": 1, "type": "timeseries", "title": "CPU per container (cores)",
+ "gridPos": { "h": 9, "w": 12, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ { "refId": "A", "expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name=~\"codestar-.+\"}[5m]))", "legendFormat": "{{name}}" }
+ ]
+ },
+ {
+ "id": 2, "type": "timeseries", "title": "Memory per container",
+ "gridPos": { "h": 9, "w": 12, "x": 12, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "sum by (name) (container_memory_usage_bytes{name=~\"codestar-.+\"})", "legendFormat": "{{name}}" }
+ ]
+ },
+ {
+ "id": 3, "type": "timeseries", "title": "Network RX per container",
+ "gridPos": { "h": 9, "w": 12, "x": 0, "y": 9 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "Bps" }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "sum by (name) (rate(container_network_receive_bytes_total{name=~\"codestar-.+\"}[5m]))", "legendFormat": "{{name}}" }
+ ]
+ },
+ {
+ "id": 4, "type": "timeseries", "title": "Network TX per container",
+ "gridPos": { "h": 9, "w": 12, "x": 12, "y": 9 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "Bps" }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "sum by (name) (rate(container_network_transmit_bytes_total{name=~\"codestar-.+\"}[5m]))", "legendFormat": "{{name}}" }
+ ]
+ }
+ ]
+}
diff --git a/monitoring/grafana/dashboards/jvm-spring.json b/monitoring/grafana/dashboards/jvm-spring.json
new file mode 100644
index 0000000..ccfbb0e
--- /dev/null
+++ b/monitoring/grafana/dashboards/jvm-spring.json
@@ -0,0 +1,70 @@
+{
+ "uid": "codestar-jvm",
+ "title": "Codestar — JVM / Spring Boot",
+ "tags": ["codestar", "backend"],
+ "timezone": "browser",
+ "schemaVersion": 39,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-6h", "to": "now" },
+ "templating": { "list": [] },
+ "panels": [
+ {
+ "id": 1, "type": "timeseries", "title": "JVM heap used",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "sum(jvm_memory_used_bytes{area=\"heap\"})", "legendFormat": "heap used" },
+ { "refId": "B", "expr": "sum(jvm_memory_max_bytes{area=\"heap\"})", "legendFormat": "heap max" }
+ ]
+ },
+ {
+ "id": 2, "type": "timeseries", "title": "GC pause rate",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "rate(jvm_gc_pause_seconds_sum[5m])", "legendFormat": "{{action}}" }
+ ]
+ },
+ {
+ "id": 3, "type": "timeseries", "title": "HTTP request rate (req/s)",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "sum by (status) (rate(http_server_requests_seconds_count[5m]))", "legendFormat": "{{status}}" }
+ ]
+ },
+ {
+ "id": 4, "type": "timeseries", "title": "HTTP p95 latency",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket[5m])))", "legendFormat": "p95" }
+ ]
+ },
+ {
+ "id": 5, "type": "stat", "title": "Live threads",
+ "gridPos": { "h": 6, "w": 8, "x": 0, "y": 16 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [ { "refId": "A", "expr": "jvm_threads_live_threads" } ]
+ },
+ {
+ "id": 6, "type": "stat", "title": "Process CPU",
+ "gridPos": { "h": 6, "w": 8, "x": 8, "y": 16 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "percentunit" }, "overrides": [] },
+ "targets": [ { "refId": "A", "expr": "process_cpu_usage" } ]
+ },
+ {
+ "id": 7, "type": "stat", "title": "Uptime",
+ "gridPos": { "h": 6, "w": 8, "x": 16, "y": 16 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
+ "targets": [ { "refId": "A", "expr": "process_uptime_seconds" } ]
+ }
+ ]
+}
diff --git a/monitoring/grafana/dashboards/logs.json b/monitoring/grafana/dashboards/logs.json
new file mode 100644
index 0000000..f5c685b
--- /dev/null
+++ b/monitoring/grafana/dashboards/logs.json
@@ -0,0 +1,44 @@
+{
+ "uid": "codestar-logs",
+ "title": "Codestar — Logs (Loki)",
+ "tags": ["codestar", "logs"],
+ "timezone": "browser",
+ "schemaVersion": 39,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-1h", "to": "now" },
+ "templating": {
+ "list": [
+ {
+ "name": "container",
+ "label": "Container",
+ "type": "query",
+ "datasource": { "type": "loki", "uid": "loki" },
+ "query": { "label": "container", "stream": "{stack=\"codestar\"}", "type": 1 },
+ "refresh": 2,
+ "includeAll": true,
+ "multi": true,
+ "current": { "text": "All", "value": "$__all" }
+ }
+ ]
+ },
+ "panels": [
+ {
+ "id": 1, "type": "timeseries", "title": "Log volume per container",
+ "gridPos": { "h": 7, "w": 24, "x": 0, "y": 0 },
+ "datasource": { "type": "loki", "uid": "loki" },
+ "targets": [
+ { "refId": "A", "expr": "sum by (container) (count_over_time({stack=\"codestar\", container=~\"$container\"}[1m]))", "legendFormat": "{{container}}" }
+ ]
+ },
+ {
+ "id": 2, "type": "logs", "title": "Logs",
+ "gridPos": { "h": 17, "w": 24, "x": 0, "y": 7 },
+ "datasource": { "type": "loki", "uid": "loki" },
+ "options": { "showTime": true, "wrapLogMessage": true, "sortOrder": "Descending", "enableLogDetails": true },
+ "targets": [
+ { "refId": "A", "expr": "{stack=\"codestar\", container=~\"$container\"}" }
+ ]
+ }
+ ]
+}
diff --git a/monitoring/grafana/dashboards/vps-system.json b/monitoring/grafana/dashboards/vps-system.json
new file mode 100644
index 0000000..08c9512
--- /dev/null
+++ b/monitoring/grafana/dashboards/vps-system.json
@@ -0,0 +1,56 @@
+{
+ "uid": "codestar-vps",
+ "title": "Codestar — VPS system",
+ "tags": ["codestar", "host"],
+ "timezone": "browser",
+ "schemaVersion": 39,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-6h", "to": "now" },
+ "templating": { "list": [] },
+ "panels": [
+ {
+ "id": 1, "type": "timeseries", "title": "CPU usage (%)",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "percent", "max": 100 }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", "legendFormat": "cpu" }
+ ]
+ },
+ {
+ "id": 2, "type": "timeseries", "title": "Memory used (%)",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "percent", "max": 100 }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100", "legendFormat": "mem" }
+ ]
+ },
+ {
+ "id": 3, "type": "timeseries", "title": "Disk free (root)",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "node_filesystem_avail_bytes{mountpoint=\"/host\"}", "legendFormat": "free" }
+ ]
+ },
+ {
+ "id": 4, "type": "timeseries", "title": "Load average (1m)",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [ { "refId": "A", "expr": "node_load1", "legendFormat": "load1" } ]
+ },
+ {
+ "id": 5, "type": "timeseries", "title": "Network traffic",
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "fieldConfig": { "defaults": { "unit": "Bps" }, "overrides": [] },
+ "targets": [
+ { "refId": "A", "expr": "sum(rate(node_network_receive_bytes_total{device!~\"lo|veth.*|docker.*|br.*\"}[5m]))", "legendFormat": "rx" },
+ { "refId": "B", "expr": "sum(rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|docker.*|br.*\"}[5m]))", "legendFormat": "tx" }
+ ]
+ }
+ ]
+}
diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml
new file mode 100644
index 0000000..1d470f0
--- /dev/null
+++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml
@@ -0,0 +1,14 @@
+# Loads every dashboard JSON dropped in /var/lib/grafana/dashboards.
+apiVersion: 1
+
+providers:
+ - name: codestar
+ orgId: 1
+ folder: Codestar
+ type: file
+ disableDeletion: false
+ updateIntervalSeconds: 30
+ allowUiUpdates: true
+ options:
+ path: /var/lib/grafana/dashboards
+ foldersFromFilesStructure: false
diff --git a/monitoring/grafana/provisioning/datasources/datasources.yml b/monitoring/grafana/provisioning/datasources/datasources.yml
new file mode 100644
index 0000000..353425d
--- /dev/null
+++ b/monitoring/grafana/provisioning/datasources/datasources.yml
@@ -0,0 +1,18 @@
+# Grafana datasources — provisioned at startup, no manual clicking.
+apiVersion: 1
+
+datasources:
+ - name: Prometheus
+ type: prometheus
+ uid: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+ editable: false
+
+ - name: Loki
+ type: loki
+ uid: loki
+ access: proxy
+ url: http://loki:3100
+ editable: false
diff --git a/monitoring/loki/loki-config.yml b/monitoring/loki/loki-config.yml
new file mode 100644
index 0000000..92f68ac
--- /dev/null
+++ b/monitoring/loki/loki-config.yml
@@ -0,0 +1,48 @@
+# Loki — single-binary, filesystem storage. Sized for a small VPS.
+# Retention decided: 7 days (168h). The compactor deletes older chunks.
+auth_enabled: false
+
+server:
+ http_listen_port: 3100
+ log_level: warn
+
+common:
+ instance_addr: 127.0.0.1
+ path_prefix: /loki
+ storage:
+ filesystem:
+ chunks_directory: /loki/chunks
+ rules_directory: /loki/rules
+ replication_factor: 1
+ ring:
+ kvstore:
+ store: inmemory
+
+schema_config:
+ configs:
+ - from: 2024-01-01
+ store: tsdb
+ object_store: filesystem
+ schema: v13
+ index:
+ prefix: index_
+ period: 24h
+
+limits_config:
+ retention_period: 168h # 7 days
+ reject_old_samples: true
+ reject_old_samples_max_age: 168h
+ ingestion_rate_mb: 8
+ ingestion_burst_size_mb: 16
+
+compactor:
+ working_directory: /loki/compactor
+ retention_enabled: true # required for retention_period to take effect
+ delete_request_store: filesystem
+
+query_range:
+ results_cache:
+ cache:
+ embedded_cache:
+ enabled: true
+ max_size_mb: 100
diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml
new file mode 100644
index 0000000..384f852
--- /dev/null
+++ b/monitoring/prometheus/prometheus.yml
@@ -0,0 +1,33 @@
+# Prometheus scrape configuration.
+# Targets are reached by their Docker container names over codestar-net.
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+scrape_configs:
+ # Prometheus itself.
+ - job_name: prometheus
+ static_configs:
+ - targets: ["localhost:9090"]
+
+ # Spring Boot backend — Micrometer exposes Prometheus format here.
+ - job_name: backend
+ metrics_path: /actuator/prometheus
+ static_configs:
+ - targets: ["codestar-backend:8080"]
+
+ # VPS host metrics (CPU / RAM / disk / network).
+ - job_name: node
+ static_configs:
+ - targets: ["codestar-node-exporter:9100"]
+
+ # Per-container metrics.
+ - job_name: cadvisor
+ static_configs:
+ - targets: ["codestar-cadvisor:8080"]
+
+ # Caddy metrics — the admin API is exposed on 2019 via the Caddyfile global block.
+ - job_name: caddy
+ metrics_path: /metrics
+ static_configs:
+ - targets: ["codestar-caddy:2019"]
diff --git a/monitoring/promtail/promtail-config.yml b/monitoring/promtail/promtail-config.yml
new file mode 100644
index 0000000..e08dc7b
--- /dev/null
+++ b/monitoring/promtail/promtail-config.yml
@@ -0,0 +1,29 @@
+# Promtail — discovers running containers via the Docker socket and ships
+# their stdout/stderr to Loki. Backend & Caddy already log JSON, so the
+# raw line is the JSON object; Grafana parses it at query time.
+server:
+ http_listen_port: 9080
+ log_level: warn
+
+positions:
+ filename: /tmp/positions.yaml
+
+clients:
+ - url: http://loki:3100/loki/api/v1/push
+
+scrape_configs:
+ - job_name: docker
+ docker_sd_configs:
+ - host: unix:///var/run/docker.sock
+ refresh_interval: 15s
+ relabel_configs:
+ # container name (strip the leading "/").
+ - source_labels: ["__meta_docker_container_name"]
+ regex: "/(.*)"
+ target_label: container
+ # compose service name when present.
+ - source_labels: ["__meta_docker_container_label_com_docker_compose_service"]
+ target_label: service
+ # Static label to find everything under the stack quickly.
+ - replacement: codestar
+ target_label: stack
diff --git a/scripts/ci/flyway-governance.sh b/scripts/ci/flyway-governance.sh
new file mode 100755
index 0000000..48731d6
--- /dev/null
+++ b/scripts/ci/flyway-governance.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# ─────────────────────────────────────────────────────────────
+# Flyway migration governance gate.
+#
+# Flyway applies each V.sql once and records its checksum. Editing or
+# deleting an already-applied migration makes Flyway refuse to start in
+# production ("checksum mismatch"). This script enforces, on every PR:
+# 1. existing migrations are never modified or deleted (only new ones added)
+# 2. version numbers are unique and strictly increasing (no gap / collision)
+#
+# Usage: flyway-governance.sh [BASE_REF]
+# BASE_REF defaults to $BASE_REF env, then origin/main.
+# Exit: 0 ok · 1 violation
+# ─────────────────────────────────────────────────────────────
+set -euo pipefail
+
+MIG_DIR="apps/backend/src/main/resources/db/migration"
+BASE="${1:-${BASE_REF:-origin/main}}"
+fail=0
+
+cd "$(git rev-parse --show-toplevel)"
+
+# Resolve base; if unknown (e.g. shallow clone), skip the diff-based check.
+if ! git rev-parse --verify --quiet "$BASE" >/dev/null; then
+ echo "! base ref '$BASE' not found — skipping modified/deleted check"
+else
+ echo "==> Checking migrations against $BASE"
+ # Status of migration files between base and HEAD: A=added M=modified D=deleted R=renamed
+ while IFS=$'\t' read -r status path rest; do
+ [ -z "$status" ] && continue
+ case "$status" in
+ M*) echo "✗ Modified an existing migration: $path (forbidden — add a new V### instead)"; fail=1 ;;
+ D*) echo "✗ Deleted an existing migration: $path (forbidden)"; fail=1 ;;
+ R*) echo "✗ Renamed an existing migration: $path -> $rest (forbidden)"; fail=1 ;;
+ A*) echo " + new migration: $path" ;;
+ esac
+ done < <(git diff --name-status "$BASE"...HEAD -- "$MIG_DIR")
+fi
+
+# Numbering integrity on the current tree: unique + contiguous (V001, V002, …).
+echo "==> Checking version numbering"
+nums="$(ls "$MIG_DIR" 2>/dev/null | grep -oE '^V[0-9]+' | grep -oE '[0-9]+' | sed 's/^0*//;s/^$/0/' | sort -n || true)"
+if [ -n "$nums" ]; then
+ dups="$(echo "$nums" | uniq -d)"
+ if [ -n "$dups" ]; then echo "✗ Duplicate migration version(s): $dups"; fail=1; fi
+ expected=1
+ for n in $nums; do
+ if [ "$n" -ne "$expected" ]; then
+ echo "✗ Non-contiguous numbering: expected V$(printf '%03d' "$expected"), found V$(printf '%03d' "$n")"
+ fail=1; break
+ fi
+ expected=$((expected + 1))
+ done
+fi
+
+if [ "$fail" -eq 0 ]; then echo "✓ Flyway governance OK"; fi
+exit "$fail"
diff --git a/scripts/ci/i18n-parity.sh b/scripts/ci/i18n-parity.sh
new file mode 100755
index 0000000..a34dcba
--- /dev/null
+++ b/scripts/ci/i18n-parity.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# ─────────────────────────────────────────────────────────────
+# i18n key-parity gate.
+#
+# English is the default locale; French ships in v1. A key present in one
+# file but missing in the other means a missing or orphan translation.
+# This compares the full set of leaf key-paths between en.json and fr.json.
+#
+# Requires: jq
+# Exit: 0 keys match · 1 mismatch
+# ─────────────────────────────────────────────────────────────
+set -euo pipefail
+
+cd "$(git rev-parse --show-toplevel)"
+DIR="apps/frontend/messages"
+EN="$DIR/en.json"
+FR="$DIR/fr.json"
+
+command -v jq >/dev/null || { echo "✗ jq is required"; exit 1; }
+[ -f "$EN" ] && [ -f "$FR" ] || { echo "✗ missing $EN or $FR"; exit 1; }
+
+# Leaf key-paths (e.g. nav.signin), sorted.
+keys() { jq -r 'paths(scalars) | join(".")' "$1" | sort; }
+
+only_en="$(comm -23 <(keys "$EN") <(keys "$FR"))"
+only_fr="$(comm -13 <(keys "$EN") <(keys "$FR"))"
+
+fail=0
+if [ -n "$only_en" ]; then
+ echo "✗ Keys in en.json missing from fr.json:"; echo "$only_en" | sed 's/^/ /'; fail=1
+fi
+if [ -n "$only_fr" ]; then
+ echo "✗ Keys in fr.json missing from en.json:"; echo "$only_fr" | sed 's/^/ /'; fail=1
+fi
+
+if [ "$fail" -eq 0 ]; then echo "✓ i18n parity OK ($(keys "$EN" | wc -l | tr -d ' ') keys)"; fi
+exit "$fail"
diff --git a/update.sh b/update.sh
new file mode 100755
index 0000000..49409c4
--- /dev/null
+++ b/update.sh
@@ -0,0 +1,140 @@
+#!/usr/bin/env bash
+# ─────────────────────────────────────────────────────────────
+# Codestar — instance updater (day-N).
+#
+# Pulls the latest code, rebuilds the stack and verifies health.
+# Safe by design: backs up the database first, and on a failed
+# health check it rolls back BOTH the code and the database.
+#
+# Run as the repo owner (so git uses the deploy key) with Docker
+# access (add the user to the "docker" group: usermod -aG docker ).
+#
+# Usage:
+# ./update.sh interactive (asks before applying)
+# ./update.sh --yes non-interactive (for systemd timer / CI)
+# ./update.sh --no-backup skip the pre-update DB dump
+# ./update.sh --help
+#
+# Exit codes: 0 = updated or already up to date · 1 = error/rolled back
+# ─────────────────────────────────────────────────────────────
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+# ── options ──────────────────────────────────────────────────
+ASSUME_YES=0
+DO_BACKUP=1
+for arg in "$@"; do
+ case "$arg" in
+ --yes|-y) ASSUME_YES=1 ;;
+ --no-backup) DO_BACKUP=0 ;;
+ --help|-h) sed -n '2,20p' "$0"; exit 0 ;;
+ *) echo "Unknown option: $arg" >&2; exit 1 ;;
+ esac
+done
+
+# ── logging ──────────────────────────────────────────────────
+c_blue=$'\033[1;34m'; c_green=$'\033[1;32m'; c_yellow=$'\033[1;33m'; c_red=$'\033[1;31m'; c_reset=$'\033[0m'
+log() { printf '%s==>%s %s\n' "$c_blue" "$c_reset" "$*"; }
+ok() { printf '%s✓%s %s\n' "$c_green" "$c_reset" "$*"; }
+warn() { printf '%s!%s %s\n' "$c_yellow" "$c_reset" "$*"; }
+err() { printf '%s✗%s %s\n' "$c_red" "$c_reset" "$*" >&2; }
+
+# ── single-instance lock (avoid concurrent updates) ──────────
+exec 9>/tmp/codestar-update.lock
+if ! flock -n 9; then err "Another update is already running."; exit 1; fi
+
+# ── prerequisites ────────────────────────────────────────────
+[ -f .env ] || { err "No .env found — is this an installed instance?"; exit 1; }
+command -v docker >/dev/null || { err "docker not found."; exit 1; }
+docker info >/dev/null 2>&1 || { err "Cannot talk to Docker. Add your user to the 'docker' group."; exit 1; }
+
+COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
+
+# DB credentials (read from .env without sourcing arbitrary content)
+DB_USER="$(grep -E '^DB_USER=' .env | head -1 | cut -d= -f2-)"
+DB_NAME="$(grep -E '^DB_NAME=' .env | head -1 | cut -d= -f2-)"
+DB_CONTAINER="codestar-db"
+BACKUP_DIR="./backups"
+KEEP_BACKUPS=7
+
+# ── helpers ──────────────────────────────────────────────────
+wait_healthy() {
+ local name="$1" tries="${2:-60}" status
+ for _ in $(seq 1 "$tries"); do
+ status="$(docker inspect -f '{{.State.Health.Status}}' "$name" 2>/dev/null || echo missing)"
+ [ "$status" = "healthy" ] && return 0
+ sleep 5
+ done
+ return 1
+}
+
+backup_db() {
+ mkdir -p "$BACKUP_DIR"
+ local f="$BACKUP_DIR/db-$(date +%Y%m%d-%H%M%S).sql.gz"
+ log "Backing up database → $f"
+ if docker exec "$DB_CONTAINER" pg_dump --clean --if-exists -U "$DB_USER" "$DB_NAME" | gzip > "$f"; then
+ ok "Backup done"
+ echo "$f"
+ # rotation: keep the most recent $KEEP_BACKUPS
+ ls -1t "$BACKUP_DIR"/db-*.sql.gz 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm -f
+ else
+ rm -f "$f"; err "Backup failed — aborting (no update applied)."; exit 1
+ fi
+}
+
+restore_db() {
+ local f="$1"
+ [ -n "$f" ] && [ -f "$f" ] || { warn "No backup to restore."; return 1; }
+ log "Restoring database from $f"
+ gunzip -c "$f" | docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" >/dev/null
+ ok "Database restored"
+}
+
+# ── 1. is there anything new? ────────────────────────────────
+log "Checking for updates…"
+git fetch --quiet
+OLD_COMMIT="$(git rev-parse HEAD)"
+NEW_COMMIT="$(git rev-parse '@{u}')"
+if [ "$OLD_COMMIT" = "$NEW_COMMIT" ]; then
+ ok "Already up to date ($(git rev-parse --short HEAD)). Nothing to do."
+ exit 0
+fi
+
+log "New version available:"
+git --no-pager log --oneline "$OLD_COMMIT..$NEW_COMMIT" | sed 's/^/ /'
+
+if [ "$ASSUME_YES" -ne 1 ]; then
+ read -rp "Apply this update? [y/N] " a
+ [ "$a" = "y" ] || [ "$a" = "Y" ] || { warn "Cancelled."; exit 0; }
+fi
+
+# ── 2. backup ────────────────────────────────────────────────
+BACKUP_FILE=""
+[ "$DO_BACKUP" -eq 1 ] && BACKUP_FILE="$(backup_db)"
+
+# ── 3. apply ─────────────────────────────────────────────────
+log "Pulling new code…"
+git merge --ff-only "$NEW_COMMIT"
+
+log "Rebuilding and restarting…"
+$COMPOSE up --build -d
+
+# ── 4. verify, else rollback ─────────────────────────────────
+log "Waiting for backend health…"
+if wait_healthy codestar-backend 60; then
+ ok "Update applied successfully → $(git rev-parse --short HEAD)"
+ docker image prune -f >/dev/null 2>&1 || true
+ exit 0
+fi
+
+err "Backend unhealthy after update. Rolling back to $(git rev-parse --short "$OLD_COMMIT")…"
+git reset --hard "$OLD_COMMIT"
+$COMPOSE up --build -d
+[ -n "$BACKUP_FILE" ] && restore_db "$BACKUP_FILE" || warn "No DB restore (backup was skipped)."
+if wait_healthy codestar-backend 60; then
+ warn "Rolled back successfully. The update was NOT applied."
+else
+ err "Rollback finished but backend still unhealthy. Check: $COMPOSE logs backend"
+fi
+exit 1