GitHub Actions CI/CD Deploy Design

Context

HMP IoT Platform currently deploys through release.sh. The script builds backend and frontend Docker images for linux/amd64, pushes them to GHCR, connects to the VPS over SSH, uploads production compose/env files, runs migrations, starts services, and verifies health.

The new workflow keeps the same production model:

  • GitHub Actions builds and publishes images to GHCR.
  • GitHub Actions deploys to the existing VPS over SSH.
  • Runtime app secrets stay on the VPS in .env.production.
  • Pull requests targeting master run lint and build checks; closing a PR as merged into master automatically deploys production.

Goals

  • Add GitHub Actions CI/CD for production deploy after PR merge to master.
  • Gate deploy with backend/frontend Biome lint plus backend/frontend builds.
  • Publish backend and frontend images to GHCR.
  • Deploy immutable image tags to the VPS over SSH.
  • Reuse the existing Docker Compose production stack and remote migration/health flow.
  • Avoid storing database, JWT, SMTP, MQTT, or MinIO runtime secrets in GitHub.

Non-Goals

  • No migration away from VPS hosting.
  • No change to docker-compose.prod.yml service topology unless required by the workflow.
  • No automatic rollback in the first version.
  • No backend Jest test gate in this first workflow.
  • No removal of release.sh; it remains a manual fallback.

Workflow Shape

Create .github/workflows/deploy.yml.

Triggers:

  • pull_request to master.

Permissions:

  • contents: read
  • packages: write

Concurrency:

  • One workflow run per pull request ref at a time.
  • Newer PR updates cancel older queued checks for the same PR.

Jobs:

  1. quality

    • Checkout repo.
    • Install Bun with oven-sh/setup-bun.
    • Run bun install --frozen-lockfile.
    • Run bun run backend:lint.
    • Run bun run frontend:lint.
    • Run bun run backend:build.
    • Run bun run frontend:build.
    • Runs on pull request open, update, reopen, ready-for-review, and close events.
  2. publish

    • Depends on quality.
    • Runs only when the pull request event action is closed and pull_request.merged is true.
    • Login to GHCR with GITHUB_TOKEN.
    • Build and push backend image from docker/backend.Dockerfile.
    • Build and push frontend image from docker/frontend.Dockerfile.
    • Target platform: linux/amd64.
    • Tags:
      • ${{ github.sha }}
      • latest
  3. deploy

    • Depends on publish.
    • Runs only when the pull request event action is closed and pull_request.merged is true.
    • Configure SSH from GitHub Actions secret.
    • Create remote app directory and backups directory if missing.
    • Upload docker-compose.prod.yml.
    • Run remote deployment commands on the VPS.

Image Naming

Use GHCR images under the current repository owner:

  • Backend: ghcr.io/<owner>/iot_platform-backend:<tag>
  • Frontend: ghcr.io/<owner>/iot_platform-frontend:<tag>

The deployed tag should be the immutable PR merge commit SHA. latest may also be pushed for human convenience, but the VPS deploy should use the SHA tag to avoid ambiguity.

Required GitHub Secrets

Transport and build-time values:

  • VPS_HOST
  • VPS_USER
  • VPS_SSH_PRIVATE_KEY
  • REMOTE_APP_DIR
  • VITE_API_URL
  • VITE_WS_URL
  • VITE_APP_NAME

GitHub should not store .env.production runtime secrets for this design.

The workflow uses fixed ports: SSH 22, backend host 3000, and frontend host 5173.

VPS Requirements

The VPS must already have:

  • Docker and Docker Compose plugin installed.
  • A readable .env.production at REMOTE_APP_DIR/.env.production.
  • Required runtime env keys in .env.production:
    • JWT_SECRET
    • POSTGRES_USER
    • POSTGRES_PASSWORD
    • POSTGRES_DB
    • DATABASE_URL
  • Permission to pull GHCR images.

If images are private and the VPS cannot pull them anonymously, add a later enhancement for remote GHCR login. The first version assumes the VPS can pull the published images.

Remote Deploy Flow

The remote script should:

  1. cd into REMOTE_APP_DIR.
  2. Create backups.
  3. Confirm .env.production exists.
  4. Validate required env keys.
  5. Export:
    • BACKEND_IMAGE
    • FRONTEND_IMAGE
    • BACKEND_HOST_PORT=3000
    • FRONTEND_HOST_PORT=5173
  6. Run docker compose -f docker-compose.prod.yml --env-file .env.production config.
  7. Pull backend/frontend images.
  8. Start infra services:
    • postgres
    • redis
    • emqx
    • minio
    • portainer
  9. Wait for hmpiot_postgres health to become healthy.
  10. Run backend migrations with docker compose run --rm backend bun run migration:run.
  11. Start backend/frontend.
  12. Verify backend/frontend containers are running.
  13. Verify backend/frontend health status is healthy or has no health check.
  14. Print docker compose ps.

On failure, print docker compose ps -a and the last backend/frontend logs.

Failure Behavior

  • Backend/frontend lint or build failure stops before publish and deploy.
  • Docker build/push failure stops before deploy.
  • Remote deploy failure exits non-zero and surfaces container status/logs.
  • No automatic rollback in first version. Existing Docker Compose state and logs remain available on the VPS for manual recovery.

Documentation Updates

Update:

  • README.md release section: production deploy now happens on merged PR close events for master.
  • docs/deployment-guide.md: document GitHub secrets, VPS .env.production ownership, and deploy sequence.

Keep:

  • release.sh as local/manual fallback.
  • Existing .vps.config.example unless a later change adds a GitHub Actions-specific example.

Verification Plan

Before merging workflow implementation:

  • Run bun run backend:lint.
  • Run bun run frontend:lint.
  • Run bun run backend:build.
  • Run bun run frontend:build.
  • Validate workflow syntax by reviewing .github/workflows/deploy.yml.
  • Confirm required GitHub secret names are documented.

After merge:

  • Watch first PR merge deployment run in GitHub Actions.
  • Confirm GHCR contains backend/frontend SHA tags.
  • Confirm VPS containers use the SHA-tagged images.
  • Confirm backend health endpoint and frontend page respond through the existing reverse proxy.

Unresolved Questions

None.