Skip to main content

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-platformsv0-connectors
CIlint + typecheck + unit tests on push/PRlint + format + typecheck + pytest + build (matrix 3.11-3.13)
CDManual rsync + sshManual CLI; scan workflow exists with GitHub secrets
Docker3-container stack (mongo, api, ui) — production-readyNo containerization
SecretsNone (auth disabled, mongo unprotected)GitHub secrets for Azure/ServiceNow credentials
Deploymentrsync + ssh docker compose up --build -dCLI --submit-graph to platform API

Platform Docker Architecture

ContainerImagePortPurpose
sv0-platform-mongomongo:7.0127.0.0.1:27017MongoDB (localhost-only, no auth)
sv0-platform-apiCustom (Node 20 Alpine, multi-stage):3000Express API
sv0-platform-uiCustom (Nginx Alpine, multi-stage):80React 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

WorkflowTriggerPurpose
entra-servicenow-ci.ymlpush/PRpytest + coverage + reports
entra-servicenow-quality.ymlpush/PR/dispatchruff lint + format + mypy + build (matrix)
entra-servicenow-scan.ymlpush/PR/dispatchRun 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)

SecretRepoUsed ForRotation
SSH_PRIVATE_KEY (or Tailscale)sv0-platformDeploy to VPSRarely
MONGO_PASSWORDsv0-platformMongoDB authRarely
ALLOWED_API_KEYSsv0-platformPlatform API authWhen keys change
TS_OAUTH_CLIENT_IDsv0-platformTailscale CI accessRarely
TS_OAUTH_SECRETsv0-platformTailscale CI accessYearly
AZURE_TENANT_IDsv0-connectorsEntra IDRarely
AZURE_CLIENT_IDsv0-connectorsEntra IDRarely
AZURE_CLIENT_SECRETsv0-connectorsEntra ID90-day rotation
SERVICENOW_INSTANCEsv0-connectorsServiceNow URLRarely
SERVICENOW_USERNAMEsv0-connectorsServiceNow authRarely
SERVICENOW_PASSWORDsv0-connectorsServiceNow auth90-day rotation
PLATFORM_URLsv0-connectorsWhere to submitRarely
PLATFORM_API_KEYsv0-connectorsPlatform authWhen 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.

CriterionAssessment
Setup complexityLow. 30 minutes. Add secrets in GitHub UI, write deploy job.
Security postureGood. Encrypted at rest (libsodium sealed boxes). Masked in logs. Environment-scoped secrets add reviewer gates. No audit log on free plan.
Blast radiusMedium. Compromised GitHub token with repo scope can read secrets via Actions API. Mitigation: fine-grained PATs, environment approvals.
Developer experienceExcellent. GitHub UI — everyone knows it.
CostFree.

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
CriterionAssessment
Setup complexityMedium. Install sops + age, generate keys, create .sops.yaml, add pre-commit hooks. ~1-2 hours.
Security postureGood. Encrypted at rest in git. Git history shows when encrypted values changed. Single master key risk.
Blast radiusHigh if age key leaks. Decrypts every .env.enc in all branches and history.
Developer experienceMedium. Must remember to encrypt before committing. Pre-commit hook essential.
CostFree.

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.

CriterionAssessment
Setup complexityMedium. Install Tailscale on VPS (~5 min). Create OAuth client. Configure ACLs. ~1 hour.
Security postureExcellent. No public SSH port. WireGuard encryption. Ephemeral CI nodes. ACL-based access. Audit log included.
Blast radiusLow. Tailscale OAuth secret only grants ephemeral, ACL-scoped access.
Developer experienceGood. Developers install Tailscale once, SSH via hostname.
CostFree 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.

CriterionAssessment
Setup complexityMedium-High. Create account, organize projects/envs, install CLI, integrate.
Security postureBest-in-class. Centralized RBAC, audit logs, automatic rotation, versioning.
Blast radiusLow. Service tokens scoped per project/env.
Developer experienceGood. Web UI + CLI.
CostFree tier available (5 users).

Verdict: Overkill for ~12 secrets across 2 repos. Consider when reaching 50+ secrets or compliance requirements.

Strategy 5: HashiCorp Vault

CriterionAssessment
SetupHigh. Own server, unsealing ceremony, policies, token management.
CostOperationally expensive. HCP Vault Secrets shutting down.
VerdictNot recommended at this scale.

Strategy 6: Self-Hosted Runner on VPS

GitHub Actions runner installed directly on the VPS.

CriterionAssessment
SetupLow. Install runner agent, register.
SecurityPoor. Any workflow has full server access. Malicious PR from fork = compromised server.
VerdictNot recommended. Significant security risk.

Recommendation

GitHub Secrets (Strategy 1) + Tailscale (Strategy 3).

Rationale:

  1. sv0-connectors already uses GitHub Secrets — same pattern, zero new tooling
  2. ~12 secrets is not enough to justify an external manager or SOPS
  3. Tailscale free tier covers 1-3 users — significant security uplift for minimal effort
  4. production GitHub environment adds required-reviewer gate before deploys
  5. Secrets flow: GitHub encrypted store → CI runner memory → Tailscale SSH → server .env

When to reconsider

TriggerMove to
Need secrets versioned per-branchSOPS + age
50+ secrets or compliance requirementsDoppler / Infisical
Multiple environments (staging, prod, dr)GitHub environment secrets (already built in)

SSH Transport: Tailscale vs Direct SSH

PatternSecuritySetupMaintenance
SSH key as GitHub secretGood. Port 22 exposed.15 minRotate key periodically
TailscaleExcellent. No public port. WireGuard. ACLs.1 hourFree tier; renew OAuth client yearly
WireGuard (manual)Excellent. No public port.2-3 hoursManage keys manually
Self-hosted runnerPoor. Full server access.30 minMonitor for compromise

Implementation Checklist

  1. Tailscale on VPScurl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up
  2. Create Tailscale OAuth client — admin console, tag:ci tag
  3. Enable MongoDB auth — update docker-compose.yml with MONGO_INITDB_ROOT_USERNAME/PASSWORD, create app user
  4. Enable platform authREQUIRE_AUTH=true, generate and set ALLOWED_API_KEYS
  5. Add GitHub secrets to sv0-platform: TS_OAUTH_CLIENT_ID, TS_OAUTH_SECRET, SSH_PRIVATE_KEY, MONGO_PASSWORD, ALLOWED_API_KEYS
  6. Add GitHub secrets to sv0-connectors: PLATFORM_URL, PLATFORM_API_KEY (Azure/ServiceNow secrets already exist)
  7. Create production environment in both repos with required reviewer
  8. Add deploy job to sv0-platform/.github/workflows/ci.yml
  9. Add submit job to sv0-connectors/.github/workflows/entra-servicenow-scan.yml
  10. Close port 22 on Hetzner firewall (Tailscale provides SSH)
  11. Test end-to-end: push to main → CI passes → deploy triggered → health check passes

References


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.