CI/CD Strategy Research
Date: 2026-02-13 Status: Proposal — pending implementation Scope: sv0-platform (build + deploy) and sv0-connectors (scan + reimport)
Current State
| sv0-platform | sv0-connectors | |
|---|---|---|
| CI | lint + typecheck + unit tests on push/PR | lint + format + typecheck + pytest + build (matrix 3.11-3.13) |
| CD | Manual rsync + ssh | Manual CLI; scan workflow exists with GitHub secrets |
| Docker | 3-container stack (mongo, api, ui) — production-ready | No containerization |
| Secrets | None (auth disabled, mongo unprotected) | GitHub secrets for Azure/ServiceNow credentials |
| Deployment | rsync + ssh docker compose up --build -d | CLI --submit-graph to platform API |
Platform Docker Architecture
| Container | Image | Port | Purpose |
|---|---|---|---|
sv0-platform-mongo | mongo:7.0 | 127.0.0.1:27017 | MongoDB (localhost-only, no auth) |
sv0-platform-api | Custom (Node 20 Alpine, multi-stage) | :3000 | Express API |
sv0-platform-ui | Custom (Nginx Alpine, multi-stage) | :80 | React SPA + API proxy |
Platform Environment Variables
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=sv0_platform
REQUIRE_AUTH=true
API_KEY_HEADER=x-api-key
TENANT_HEADER=x-tenant-id
ALLOWED_API_KEYS=local-dev-key
NODE_ENV=production
LOG_LEVEL=info
CORS_ALLOWED_ORIGINS=http://localhost:3000
Connector Credentials
AZURE_TENANT_ID=<uuid>
AZURE_CLIENT_ID=<app-registration-client-id>
AZURE_CLIENT_SECRET=<secret>
SERVICENOW_INSTANCE=dev12345
SERVICENOW_USERNAME=integration_user
SERVICENOW_PASSWORD=<password>
Existing Connector Workflows
| Workflow | Trigger | Purpose |
|---|---|---|
entra-servicenow-ci.yml | push/PR | pytest + coverage + reports |
entra-servicenow-quality.yml | push/PR/dispatch | ruff lint + format + mypy + build (matrix) |
entra-servicenow-scan.yml | push/PR/dispatch | Run scans, upload artifacts |
Proposed Workflows
Workflow 1: Platform Build + Deploy
Trigger: push to main, workflow_dispatch
push to main (or manual dispatch)
│
├── Job: build-test (existing — runs on every push/PR)
│ └── lint → typecheck → unit tests → build
│
└── Job: deploy (needs: build-test, only on main)
├── Connect to VPS (Tailscale or SSH key)
├── rsync source (exclude node_modules, dist, data, .git)
├── Generate .env from GitHub secrets on server
├── docker compose up --build -d
└── Health check: curl /health (retry with backoff)
Workflow YAML (platform)
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
skip_deploy:
description: 'Skip deployment (test only)'
required: false
type: boolean
default: false
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm install
- run: npm run ci
- run: cd ui && npm install
- run: cd ui && npm run ci
deploy:
needs: build-test
if: github.ref == 'refs/heads/main' && github.event.inputs.skip_deploy != 'true'
runs-on: ubuntu-latest
environment: production # requires reviewer approval
steps:
- uses: actions/checkout@v4
- name: Connect to Tailscale
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
- name: Deploy to server
run: |
rsync -avz --delete \
--exclude 'node_modules' --exclude 'dist' --exclude '.git' \
--exclude 'data' --exclude 'ui/node_modules' --exclude 'ui/dist' \
--exclude '.env' \
-e "ssh -o StrictHostKeyChecking=no" \
./ deploy@sv0-hetzner:~/sv0-platform/
- name: Configure and start
uses: appleboy/ssh-action@v1.0.3
with:
host: sv0-hetzner # Tailscale hostname
username: deploy
key: ${{ secrets.SSH_PRIVATE_KEY }}
envs: MONGO_PASSWORD,API_KEYS
script: |
cat << 'EOF' > ~/sv0-platform/.env
MONGODB_URI=mongodb://sv0user:${MONGO_PASSWORD}@mongo:27017/sv0_platform
REQUIRE_AUTH=true
ALLOWED_API_KEYS=${API_KEYS}
NODE_ENV=production
LOG_LEVEL=info
EOF
cd ~/sv0-platform && docker compose up --build -d
env:
MONGO_PASSWORD: ${{ secrets.MONGO_PASSWORD }}
API_KEYS: ${{ secrets.ALLOWED_API_KEYS }}
- name: Health check
run: |
for i in 1 2 3 4 5; do
if curl -sf http://sv0-hetzner:3000/health; then
echo "Health check passed"
exit 0
fi
echo "Attempt $i failed, waiting 10s..."
sleep 10
done
echo "Health check failed after 5 attempts"
exit 1
Workflow 2: Connector Scan + Reimport
Trigger: workflow_dispatch (manual), optionally push to main or schedule
workflow_dispatch (or push/schedule)
inputs: scan_mode, submit_to_platform, environment
│
├── Job: scan-and-report (always)
│ ├── Setup Python 3.13
│ ├── Run: --all --graph-json graph.json --json results.json --md report.md
│ ├── Upload artifacts (graph.json, report.md, results.json)
│ └── Write markdown summary to GitHub Step Summary
│
└── Job: submit-to-platform (needs: scan-and-report, if submit_to_platform)
├── Download graph.json artifact
├── --submit-graph graph.json --platform-url $PLATFORM_URL
└── Verify: curl /health, check sync status
Parallel outputs: MD/JSON artifacts are always available for quick review in GitHub Actions, platform submission is optional and gated.
Workflow YAML (connector scan + reimport)
name: Scan & Import
on:
workflow_dispatch:
inputs:
scan_mode:
description: 'Scan mode'
required: true
type: choice
options:
- all
- integrations
- chains
default: all
submit_to_platform:
description: 'Submit graph to platform after scan'
required: true
type: boolean
default: false
# Optional: uncomment for scheduled scans
# schedule:
# - cron: '0 2 * * 1' # Weekly Monday 2am UTC
env:
AZURE_TENANT_ID: ${{ secrets.ENTRA_SERVICENOW_AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.ENTRA_SERVICENOW_AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.ENTRA_SERVICENOW_AZURE_CLIENT_SECRET }}
SERVICENOW_INSTANCE: ${{ secrets.ENTRA_SERVICENOW_SNOW_INSTANCE }}
SERVICENOW_USERNAME: ${{ secrets.ENTRA_SERVICENOW_SNOW_USERNAME }}
SERVICENOW_PASSWORD: ${{ secrets.ENTRA_SERVICENOW_SNOW_PASSWORD }}
jobs:
scan-and-report:
runs-on: ubuntu-latest
defaults:
run:
working-directory: integrations/entra-servicenow
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: pip
cache-dependency-path: integrations/entra-servicenow/requirements.txt
- run: pip install -r requirements.txt
- name: Run scan
run: |
SCAN_FLAG=""
case "${{ github.event.inputs.scan_mode }}" in
all) SCAN_FLAG="--all" ;;
integrations) SCAN_FLAG="" ;;
chains) SCAN_FLAG="--chains" ;;
esac
python -m entra_servicenow.cli.main $SCAN_FLAG \
--graph-json reports/graph.json \
--json reports/results.json \
--md reports/report.md \
--verbose
- name: Summary
if: always()
run: |
echo "## Scan Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f reports/report.md ]; then
cat reports/report.md >> $GITHUB_STEP_SUMMARY
fi
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: scan-${{ github.run_number }}
path: |
integrations/entra-servicenow/reports/graph.json
integrations/entra-servicenow/reports/results.json
integrations/entra-servicenow/reports/report.md
retention-days: 90
submit-to-platform:
needs: scan-and-report
if: github.event.inputs.submit_to_platform == 'true'
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: integrations/entra-servicenow
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: pip
cache-dependency-path: integrations/entra-servicenow/requirements.txt
- run: pip install -r requirements.txt
- name: Download graph artifact
uses: actions/download-artifact@v4
with:
name: scan-${{ github.run_number }}
path: integrations/entra-servicenow/
- name: Submit to platform
env:
PLATFORM_URL: ${{ secrets.PLATFORM_URL }}
PLATFORM_API_KEY: ${{ secrets.PLATFORM_API_KEY }}
run: |
python -m entra_servicenow.cli.main \
--submit-graph reports/graph.json \
--platform-url "$PLATFORM_URL" \
--api-key "$PLATFORM_API_KEY" \
--tenant-id default
- name: Verify ingest
env:
PLATFORM_URL: ${{ secrets.PLATFORM_URL }}
PLATFORM_API_KEY: ${{ secrets.PLATFORM_API_KEY }}
run: |
sleep 5
curl -sf "$PLATFORM_URL/health" || (echo "Platform health check failed" && exit 1)
echo "Platform is healthy after import"
Secrets Management Evaluation
Secrets Inventory (~12 secrets)
| Secret | Repo | Used For | Rotation |
|---|---|---|---|
SSH_PRIVATE_KEY (or Tailscale) | sv0-platform | Deploy to VPS | Rarely |
MONGO_PASSWORD | sv0-platform | MongoDB auth | Rarely |
ALLOWED_API_KEYS | sv0-platform | Platform API auth | When keys change |
TS_OAUTH_CLIENT_ID | sv0-platform | Tailscale CI access | Rarely |
TS_OAUTH_SECRET | sv0-platform | Tailscale CI access | Yearly |
AZURE_TENANT_ID | sv0-connectors | Entra ID | Rarely |
AZURE_CLIENT_ID | sv0-connectors | Entra ID | Rarely |
AZURE_CLIENT_SECRET | sv0-connectors | Entra ID | 90-day rotation |
SERVICENOW_INSTANCE | sv0-connectors | ServiceNow URL | Rarely |
SERVICENOW_USERNAME | sv0-connectors | ServiceNow auth | Rarely |
SERVICENOW_PASSWORD | sv0-connectors | ServiceNow auth | 90-day rotation |
PLATFORM_URL | sv0-connectors | Where to submit | Rarely |
PLATFORM_API_KEY | sv0-connectors | Platform auth | When keys change |
Strategy Comparison
Strategy 1: GitHub Repo/Environment Secrets → inject .env at deploy time
Secrets stored in GitHub Settings > Secrets. Workflow creates .env on the server via SSH at deploy time. .env never exists in the repo or CI artifacts.
| Criterion | Assessment |
|---|---|
| Setup complexity | Low. 30 minutes. Add secrets in GitHub UI, write deploy job. |
| Security posture | Good. Encrypted at rest (libsodium sealed boxes). Masked in logs. Environment-scoped secrets add reviewer gates. No audit log on free plan. |
| Blast radius | Medium. Compromised GitHub token with repo scope can read secrets via Actions API. Mitigation: fine-grained PATs, environment approvals. |
| Developer experience | Excellent. GitHub UI — everyone knows it. |
| Cost | Free. |
Pros: Zero new tooling. sv0-connectors already uses this. Environment-scoped secrets can require approval.
Cons: No audit trail (free plan). Manual rotation. .env readable on VPS filesystem.
Strategy 2: SOPS + age (encrypted .env committed to repo)
.env files encrypted with age via sops. Committed as .env.enc. One GitHub secret holds the age private key. CI decrypts at deploy time.
# .sops.yaml (committed)
creation_rules:
- path_regex: \.env\.enc$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
| Criterion | Assessment |
|---|---|
| Setup complexity | Medium. Install sops + age, generate keys, create .sops.yaml, add pre-commit hooks. ~1-2 hours. |
| Security posture | Good. Encrypted at rest in git. Git history shows when encrypted values changed. Single master key risk. |
| Blast radius | High if age key leaks. Decrypts every .env.enc in all branches and history. |
| Developer experience | Medium. Must remember to encrypt before committing. Pre-commit hook essential. |
| Cost | Free. |
Pros: Secrets versioned in git. Only one GitHub secret needed. Works offline. Cons: Accidental plaintext commit is catastrophic. Age key rotation requires re-encrypting all files. Every developer needs sops + age installed.
Strategy 3: Tailscale VPN + server-side .env
Tailscale on the VPS. CI joins tailnet via tailscale/github-action@v4. SSH via Tailscale hostname. Close public port 22. .env lives on server — never leaves.
| Criterion | Assessment |
|---|---|
| Setup complexity | Medium. Install Tailscale on VPS (~5 min). Create OAuth client. Configure ACLs. ~1 hour. |
| Security posture | Excellent. No public SSH port. WireGuard encryption. Ephemeral CI nodes. ACL-based access. Audit log included. |
| Blast radius | Low. Tailscale OAuth secret only grants ephemeral, ACL-scoped access. |
| Developer experience | Good. Developers install Tailscale once, SSH via hostname. |
| Cost | Free for up to 3 users. Paid: $6/user/month. |
Pros: Eliminates public SSH exposure. WireGuard encryption. Ephemeral runners auto-cleaned. Cons: Doesn't solve "how secrets get onto the server initially" — combine with Strategy 1. Tailscale outage blocks deploys.
Strategy 4: External Secrets Manager (Doppler / Infisical)
Centralized hosted secrets manager. CI fetches secrets at deploy time.
| Criterion | Assessment |
|---|---|
| Setup complexity | Medium-High. Create account, organize projects/envs, install CLI, integrate. |
| Security posture | Best-in-class. Centralized RBAC, audit logs, automatic rotation, versioning. |
| Blast radius | Low. Service tokens scoped per project/env. |
| Developer experience | Good. Web UI + CLI. |
| Cost | Free tier available (5 users). |
Verdict: Overkill for ~12 secrets across 2 repos. Consider when reaching 50+ secrets or compliance requirements.
Strategy 5: HashiCorp Vault
| Criterion | Assessment |
|---|---|
| Setup | High. Own server, unsealing ceremony, policies, token management. |
| Cost | Operationally expensive. HCP Vault Secrets shutting down. |
| Verdict | Not recommended at this scale. |
Strategy 6: Self-Hosted Runner on VPS
GitHub Actions runner installed directly on the VPS.
| Criterion | Assessment |
|---|---|
| Setup | Low. Install runner agent, register. |
| Security | Poor. Any workflow has full server access. Malicious PR from fork = compromised server. |
| Verdict | Not recommended. Significant security risk. |
Recommendation
GitHub Secrets (Strategy 1) + Tailscale (Strategy 3).
Rationale:
- sv0-connectors already uses GitHub Secrets — same pattern, zero new tooling
- ~12 secrets is not enough to justify an external manager or SOPS
- Tailscale free tier covers 1-3 users — significant security uplift for minimal effort
productionGitHub environment adds required-reviewer gate before deploys- Secrets flow: GitHub encrypted store → CI runner memory → Tailscale SSH → server
.env
When to reconsider
| Trigger | Move to |
|---|---|
| Need secrets versioned per-branch | SOPS + age |
| 50+ secrets or compliance requirements | Doppler / Infisical |
| Multiple environments (staging, prod, dr) | GitHub environment secrets (already built in) |
SSH Transport: Tailscale vs Direct SSH
| Pattern | Security | Setup | Maintenance |
|---|---|---|---|
| SSH key as GitHub secret | Good. Port 22 exposed. | 15 min | Rotate key periodically |
| Tailscale | Excellent. No public port. WireGuard. ACLs. | 1 hour | Free tier; renew OAuth client yearly |
| WireGuard (manual) | Excellent. No public port. | 2-3 hours | Manage keys manually |
| Self-hosted runner | Poor. Full server access. | 30 min | Monitor for compromise |
Implementation Checklist
- Tailscale on VPS —
curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up - Create Tailscale OAuth client — admin console,
tag:citag - Enable MongoDB auth — update
docker-compose.ymlwithMONGO_INITDB_ROOT_USERNAME/PASSWORD, create app user - Enable platform auth —
REQUIRE_AUTH=true, generate and setALLOWED_API_KEYS - Add GitHub secrets to sv0-platform:
TS_OAUTH_CLIENT_ID,TS_OAUTH_SECRET,SSH_PRIVATE_KEY,MONGO_PASSWORD,ALLOWED_API_KEYS - Add GitHub secrets to sv0-connectors:
PLATFORM_URL,PLATFORM_API_KEY(Azure/ServiceNow secrets already exist) - Create
productionenvironment in both repos with required reviewer - Add deploy job to
sv0-platform/.github/workflows/ci.yml - Add submit job to
sv0-connectors/.github/workflows/entra-servicenow-scan.yml - Close port 22 on Hetzner firewall (Tailscale provides SSH)
- Test end-to-end: push to main → CI passes → deploy triggered → health check passes
References
- Managing secrets in Docker Compose + GitHub Actions
- SOPS + age: Secure Environment Files in Git
- SOPS comprehensive guide (GitGuardian)
- Docker Compose deployment via GitHub Actions + Tailscale
- Tailscale GitHub Action v4
- Deploy with Tailscale SSH + GitHub Actions
- CI/CD with Docker, GitHub Actions, and Hetzner
- Self-hosted runner security risks (Sysdig)
- HashiCorp Vault Secrets shutdown
Next Action
Status: adopted — shipped
GitHub Actions CI, SOPS+age secrets management, and Docker image tagging strategy adopted. Implemented in .github/workflows/ across all repos. No further action required.