GitHub Actions CI/CD Implementation Plan
Status: REMOVED — Free GitHub accounts cannot use environment variables or secrets in private repositories. See docs/deployment-guide.md for the current manual deployment approach.
Original Goal: Add production GitHub Actions CI/CD that validates pull requests to master and deploys to the existing VPS when a PR is closed as merged.
Original Architecture: A single workflow gates deploy with Bun install, backend/frontend Biome lint, backend build, and frontend build. Docker Buildx publishes SHA-tagged backend/frontend images to GHCR, then an SSH deploy step uploads the production compose file and runs the existing remote compose/migration/health sequence while keeping .env.production on the VPS.
Removal Reason: Free GitHub accounts (including this private repo) do not have access to repository environments, secrets, or variables in private repositories. The workflow relied on ${{ vars.VITE_API_URL }}, ${{ secrets.VPS_HOST }}, and other secrets/variables which are unavailable on free plans.
Current State: Use release.sh for manual deployment. See docs/deployment-guide.md for details.
Task 1: Add Production Deploy Workflow (DEPRECATED)
Files:
Create:
.github/workflows/deploy.ymlStep 1: Create workflow directory
Run: mkdir -p .github/workflows
Expected: .github/workflows exists.
- Step 2: Add deploy workflow
Create .github/workflows/deploy.yml with:
name: Deploy Production
on:
pull_request:
branches:
- master
types:
- opened
- synchronize
- reopened
- ready_for_review
- closed
permissions:
contents: read
packages: write
concurrency:
group: deploy-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
BACKEND_IMAGE_NAME: iot_platform-backend
FRONTEND_IMAGE_NAME: iot_platform-frontend
VPS_PORT: "22"
BACKEND_HOST_PORT: "3000"
FRONTEND_HOST_PORT: "5173"
jobs:
quality:
name: Lint and build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.action == 'closed' && github.event.pull_request.merge_commit_sha || github.event.pull_request.head.sha }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint backend
run: bun run backend:lint
- name: Lint frontend
run: bun run frontend:lint
- name: Build backend
run: bun run backend:build
- name: Build frontend
run: bun run frontend:build
publish:
name: Build and publish images
runs-on: ubuntu-latest
needs: quality
if: github.event.action == 'closed' && github.event.pull_request.merged == true
outputs:
backend_image: ${{ steps.images.outputs.backend_image }}
frontend_image: ${{ steps.images.outputs.frontend_image }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
- name: Prepare image names
id: images
shell: bash
run: |
owner_lc="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
deploy_sha="${{ github.event.pull_request.merge_commit_sha }}"
backend_image="${REGISTRY}/${owner_lc}/${BACKEND_IMAGE_NAME}:${deploy_sha}"
frontend_image="${REGISTRY}/${owner_lc}/${FRONTEND_IMAGE_NAME}:${deploy_sha}"
backend_latest="${REGISTRY}/${owner_lc}/${BACKEND_IMAGE_NAME}:latest"
frontend_latest="${REGISTRY}/${owner_lc}/${FRONTEND_IMAGE_NAME}:latest"
{
echo "backend_image=${backend_image}"
echo "frontend_image=${frontend_image}"
echo "backend_latest=${backend_latest}"
echo "frontend_latest=${frontend_latest}"
} >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push backend
uses: docker/build-push-action@v6
with:
context: .
file: docker/backend.Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ steps.images.outputs.backend_image }}
${{ steps.images.outputs.backend_latest }}
- name: Build and push frontend
uses: docker/build-push-action@v6
with:
context: .
file: docker/frontend.Dockerfile
platforms: linux/amd64
push: true
build-args: |
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_WS_URL=${{ secrets.VITE_WS_URL }}
VITE_APP_NAME=${{ secrets.VITE_APP_NAME }}
tags: |
${{ steps.images.outputs.frontend_image }}
${{ steps.images.outputs.frontend_latest }}
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
needs: publish
if: github.event.action == 'closed' && github.event.pull_request.merged == true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure SSH
shell: bash
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.VPS_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$VPS_PORT" "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts
- name: Prepare remote directory
shell: bash
run: |
ssh -i ~/.ssh/deploy_key -p "$VPS_PORT" "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" \
"mkdir -p '${{ secrets.REMOTE_APP_DIR }}/backups'"
- name: Upload compose file
shell: bash
run: |
scp -i ~/.ssh/deploy_key -P "$VPS_PORT" docker-compose.prod.yml \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:${{ secrets.REMOTE_APP_DIR }}/docker-compose.prod.yml"
- name: Deploy release
shell: bash
env:
BACKEND_IMAGE: ${{ needs.publish.outputs.backend_image }}
FRONTEND_IMAGE: ${{ needs.publish.outputs.frontend_image }}
REMOTE_APP_DIR: ${{ secrets.REMOTE_APP_DIR }}
run: |
remote_script="$(mktemp)"
cat > "$remote_script" <<'REMOTE_SCRIPT'
set -euo pipefail
cd "$REMOTE_APP_DIR"
mkdir -p backups
if [ ! -f .env.production ]; then
echo "Missing .env.production in $REMOTE_APP_DIR" >&2
exit 1
fi
required_env_keys=(
JWT_SECRET
POSTGRES_USER
POSTGRES_PASSWORD
POSTGRES_DB
DATABASE_URL
)
for key in "${required_env_keys[@]}"; do
if ! grep -Eq "^${key}=.+" .env.production; then
echo "Missing required env in .env.production: ${key}" >&2
exit 1
fi
done
export BACKEND_IMAGE
export FRONTEND_IMAGE
export BACKEND_HOST_PORT
export FRONTEND_HOST_PORT
docker compose -f docker-compose.prod.yml --env-file .env.production config >/dev/null
docker compose -f docker-compose.prod.yml --env-file .env.production pull backend frontend
docker compose -f docker-compose.prod.yml --env-file .env.production up -d postgres redis emqx minio portainer
echo "Waiting for postgres to be healthy..."
until [ "$(docker inspect --format='{{.State.Health.Status}}' hmpiot_postgres 2>/dev/null)" = "healthy" ]; do
sleep 3
done
echo "Postgres is healthy."
echo "Running database migrations..."
docker compose -f docker-compose.prod.yml --env-file .env.production run --rm backend bun run migration:run
echo "Migrations completed."
docker compose -f docker-compose.prod.yml --env-file .env.production up -d backend frontend
echo "Waiting for backend/frontend containers to be running..."
check_container_running() {
local name="$1"
local tries=0
while [ "$tries" -lt 20 ]; do
if [ "$(docker inspect --format='{{.State.Running}}' "$name" 2>/dev/null || true)" = "true" ]; then
return 0
fi
tries=$((tries + 1))
sleep 3
done
return 1
}
if ! check_container_running hmpiot_backend || ! check_container_running hmpiot_frontend; then
echo "Error: backend/frontend container is not running after deployment." >&2
docker compose -f docker-compose.prod.yml --env-file .env.production ps -a || true
docker compose -f docker-compose.prod.yml --env-file .env.production logs --tail=200 backend frontend || true
exit 1
fi
echo "Checking backend/frontend health status..."
check_health() {
local name="$1"
local tries=0
while [ "$tries" -lt 20 ]; do
status="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null || true)"
if [ "$status" = "healthy" ] || [ "$status" = "none" ]; then
return 0
fi
if [ "$status" = "unhealthy" ]; then
return 1
fi
tries=$((tries + 1))
sleep 3
done
return 1
}
if ! check_health hmpiot_backend || ! check_health hmpiot_frontend; then
echo "Error: backend/frontend container did not become healthy." >&2
docker compose -f docker-compose.prod.yml --env-file .env.production ps -a || true
docker compose -f docker-compose.prod.yml --env-file .env.production logs --tail=200 backend frontend || true
exit 1
fi
docker compose -f docker-compose.prod.yml --env-file .env.production ps
REMOTE_SCRIPT
ssh -i ~/.ssh/deploy_key -p "$VPS_PORT" "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" \
"BACKEND_IMAGE='${BACKEND_IMAGE}' FRONTEND_IMAGE='${FRONTEND_IMAGE}' BACKEND_HOST_PORT='${BACKEND_HOST_PORT}' FRONTEND_HOST_PORT='${FRONTEND_HOST_PORT}' REMOTE_APP_DIR='${REMOTE_APP_DIR}' bash -s" < "$remote_script"
- Step 3: Review workflow YAML
Run: sed -n '1,260p' .github/workflows/deploy.yml
Expected: workflow includes quality, publish, and deploy jobs.
Task 2: Update Deployment Documentation
Files:
Modify:
README.mdModify:
docs/deployment-guide.mdStep 1: Update README release section
Replace the release section with text that says PRs to master run lint/build and production deploy happens when a PR is closed as merged into master; list required secrets and keep release.sh as manual fallback.
- Step 2: Update deployment guide
Update docs/deployment-guide.md with GitHub Actions source of truth, required secrets, VPS .env.production ownership, deploy sequence, and local fallback notes.
- Step 3: Review docs
Run: sed -n '1,220p' README.md and sed -n '1,260p' docs/deployment-guide.md
Expected: docs match the workflow and do not say .env.production is uploaded by GitHub Actions.
Task 3: Verify and Commit
Files:
Verify:
.github/workflows/deploy.ymlVerify:
README.mdVerify:
docs/deployment-guide.mdVerify:
docs/superpowers/plans/2026-04-18-github-actions-cicd.mdStep 1: Run backend lint
Run: bun run backend:lint
Expected: command exits 0.
- Step 2: Run frontend lint
Run: bun run frontend:lint
Expected: command exits 0.
- Step 3: Run backend build
Run: bun run backend:build
Expected: command exits 0.
- Step 4: Run frontend build
Run: bun run frontend:build
Expected: command exits 0.
- Step 5: Check git diff
Run: git diff --stat
Expected: only workflow, docs, and plan files changed.
- Step 6: Commit
Run:
git add .github/workflows/deploy.yml README.md docs/deployment-guide.md docs/superpowers/plans/2026-04-18-github-actions-cicd.md
git commit -m "ci: add github actions production deploy"
Expected: commit succeeds.