tutorial

How to Monitor Score Spec Workload Deployments with Vigilmon

Score is a platform-agnostic workload specification format that lets developers describe their service once and deploy it to any environment — Docker Compose...

Score is a platform-agnostic workload specification format that lets developers describe their service once and deploy it to any environment — Docker Compose for local development, Kubernetes for staging, and cloud-managed platforms for production — without rewriting infrastructure config. Because Score abstracts away the target platform, it can also abstract away visibility: a score-compose up that succeeds locally may produce a score-k8s manifest that deploys correctly to one environment but silently misconfigures another.

In this tutorial you'll set up monitoring for Score-managed workloads using Vigilmon — free tier, no credit card required.


Why Score-managed workloads need external monitoring

Score's translation layer — score-compose or score-k8s — converts a platform-agnostic spec into environment-specific manifests. This translation is a potential source of drift: a field supported in Compose is silently dropped in the Kubernetes translation, a resource reference resolves differently across environments, or a platform extension patches the spec in a way that changes behavior without changing the Score file.

Key failure modes that Score tooling doesn't catch:

  • Translation parity failuresscore-compose and score-k8s produce semantically different configurations from the same Score file; the service behaves differently in prod than in local testing
  • Resource provisioner failures — Score's resources block depends on environment-specific provisioners (databases, queues, buckets); when a provisioner silently fails to inject the right connection string, the workload starts but immediately fails on first use
  • Score extension override driftscore-k8s patches (in overrides.yaml) override Score defaults in a per-environment way; when the Kubernetes cluster is upgraded and the patch no longer applies correctly, the workload runs with wrong configuration
  • Deployment success masking health failures — the Score-generated Kubernetes Deployment shows Available, but the container is actually crashing because an environment variable the Score spec expected is missing from the provisioner output
  • Multi-environment parity loss — the Score file is the source of truth, but downstream environments diverge as teams patch platform-specific configs directly without updating the Score spec
  • Platform migration failures — when migrating a workload from score-compose to score-k8s, the translation succeeds but the service's exposed ports, health check paths, or resource limits differ from what was tested

External HTTP monitoring validates what the workload actually does in each environment, independent of what the deployment tooling reports.


What you'll need

  • At least one Score-managed workload running in a reachable environment
  • A Score file (score.yaml) with an HTTP service defined
  • A free Vigilmon account (sign up in 30 seconds)

Step 1: Define a health endpoint in your Score spec

Score workloads that expose an HTTP service should include a health check endpoint. Define it in the Score spec so it's consistent across all platforms:

# score.yaml
apiVersion: score.dev/v1b1
kind: Workload
metadata:
  name: api-service
spec:
  containers:
    api:
      image: your-registry/api-service:latest
      variables:
        DATABASE_URL: ${resources.db.connection_string}
        CACHE_URL: ${resources.cache.connection_string}
        ENV_NAME: ${metadata.environment}
      livenessProbe:
        httpGet:
          path: /health
          port: 8080
        initialDelaySeconds: 10
        periodSeconds: 30
      readinessProbe:
        httpGet:
          path: /health
          port: 8080
        initialDelaySeconds: 5
        periodSeconds: 10
  service:
    ports:
      www:
        port: 80
        targetPort: 8080
  resources:
    db:
      type: postgres
    cache:
      type: redis

Your application's /health endpoint should validate that injected resources are reachable:

// health.js (Node.js example)
const { Pool } = require('pg');
const redis = require('redis');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const cacheClient = redis.createClient({ url: process.env.CACHE_URL });

app.get('/health', async (req, res) => {
  const checks = {};
  let allOk = true;

  // Check database connectivity
  try {
    await pool.query('SELECT 1');
    checks.database = 'ok';
  } catch (e) {
    checks.database = 'error';
    allOk = false;
  }

  // Check cache connectivity
  try {
    await cacheClient.ping();
    checks.cache = 'ok';
  } catch (e) {
    checks.cache = 'error';
    allOk = false;
  }

  const status = allOk ? 200 : 503;
  res.status(status).json({
    status: allOk ? 'ok' : 'degraded',
    environment: process.env.ENV_NAME || 'unknown',
    checks,
  });
});

Step 2: Validate Score translation produces correct manifests

Add a CI step that validates the Score-to-platform translation and confirms the output matches expectations:

#!/bin/bash
# validate-score-translation.sh
set -euo pipefail

SCORE_FILE="${1:-score.yaml}"
REPORT_URL="${2:-http://localhost:9400/translation-result}"
ENV="${3:-staging}"

# Generate platform manifests
score-k8s generate "$SCORE_FILE" \
    --override-file "overrides.$ENV.yaml" \
    --output "generated-$ENV.yaml"

# Validate expected fields are present in the output
CHECKS_PASSED=0
CHECKS_FAILED=0

check_field() {
    local description="$1"
    local value="$2"
    if [ -n "$value" ]; then
        CHECKS_PASSED=$((CHECKS_PASSED + 1))
    else
        echo "FAIL: $description"
        CHECKS_FAILED=$((CHECKS_FAILED + 1))
    fi
}

# Check that health probe survived translation
check_field "liveness probe" "$(grep -c "livenessProbe" "generated-$ENV.yaml" || echo "")"
# Check that resource env vars are present
check_field "DATABASE_URL env var" "$(grep -c "DATABASE_URL" "generated-$ENV.yaml" || echo "")"
check_field "service port" "$(grep -c "port: 80" "generated-$ENV.yaml" || echo "")"

# Report result
STATUS="success"
if [ "$CHECKS_FAILED" -gt 0 ]; then
    STATUS="error"
fi

curl -sf -X POST "$REPORT_URL" \
    -H "Content-Type: application/json" \
    -d "{\"env\": \"$ENV\", \"status\": \"$STATUS\", \"passed\": $CHECKS_PASSED, \"failed\": $CHECKS_FAILED}" \
    || true

[ "$CHECKS_FAILED" -eq 0 ]

Step 3: Build a translation and deployment health API

Collect validation results and deployment outcomes for Vigilmon to poll:

# score-health-server/app.py
from flask import Flask, jsonify, request
from collections import defaultdict
import time
import threading

app = Flask(__name__)
lock = threading.Lock()

translation_results = {}   # env -> last result
deployment_status = {}     # env -> {"status": ok/error, "timestamp": ...}

MAX_TRANSLATION_AGE_S = 3600  # alert if no CI run in 1 hour
MAX_DEPLOY_AGE_S = 86400      # alert if no successful deploy in 24 hours

@app.route("/translation-result", methods=["POST"])
def record_translation():
    data = request.get_json()
    env = data.get("env", "unknown")
    with lock:
        translation_results[env] = {
            "status": data.get("status"),
            "passed": data.get("passed", 0),
            "failed": data.get("failed", 0),
            "timestamp": time.time(),
        }
    return jsonify({"ok": True})

@app.route("/deploy-result", methods=["POST"])
def record_deploy():
    data = request.get_json()
    env = data.get("env", "unknown")
    with lock:
        deployment_status[env] = {
            "status": data.get("status"),
            "timestamp": time.time(),
        }
    return jsonify({"ok": True})

@app.route("/health/translation")
def translation_health():
    now = time.time()
    with lock:
        results = dict(translation_results)

    if not results:
        return jsonify({"status": "unknown", "detail": "no translation results yet"}), 200

    issues = []
    for env, result in results.items():
        age_s = now - result["timestamp"]
        if result["status"] != "success":
            issues.append(f"{env}: translation failed ({result['failed']} checks failed)")
        elif age_s > MAX_TRANSLATION_AGE_S:
            issues.append(f"{env}: no translation run in {round(age_s/60)}m")

    if issues:
        return jsonify({"status": "degraded", "issues": issues}), 503

    return jsonify({
        "status": "ok",
        "environments": list(results.keys()),
    })

@app.route("/health/deployment")
def deployment_health():
    now = time.time()
    with lock:
        results = dict(deployment_status)

    if not results:
        return jsonify({"status": "unknown"}), 200

    issues = []
    for env, result in results.items():
        age_s = now - result["timestamp"]
        if result["status"] != "success":
            issues.append(f"{env}: last deploy failed")
        elif age_s > MAX_DEPLOY_AGE_S and env == "production":
            issues.append(f"{env}: no successful deploy in {round(age_s/3600)}h")

    if issues:
        return jsonify({"status": "degraded", "issues": issues}), 503

    return jsonify({
        "status": "ok",
        "environments": list(results.keys()),
    })

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=9400)

Step 4: Report deployment outcomes from CI

Add a step to your deployment pipeline that reports to the health server:

# .github/workflows/deploy.yml
name: Deploy with Score

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Score tools
        run: |
          curl -Lo score-k8s https://github.com/score-spec/score-k8s/releases/latest/download/score-k8s-linux-amd64
          chmod +x score-k8s && sudo mv score-k8s /usr/local/bin/

      - name: Validate Score translation
        id: validate
        run: |
          bash validate-score-translation.sh score.yaml \
            ${{ secrets.HEALTH_SERVER_URL }}/translation-result \
            staging

      - name: Deploy to staging
        id: deploy
        run: |
          score-k8s generate score.yaml --override-file overrides.staging.yaml | \
            kubectl apply -f -
          kubectl rollout status deployment/api-service --timeout=5m

      - name: Report deployment result
        if: always()
        run: |
          STATUS="${{ steps.deploy.outcome }}"
          curl -sf -X POST "${{ secrets.HEALTH_SERVER_URL }}/deploy-result" \
            -H "Content-Type: application/json" \
            -d "{\"env\": \"staging\", \"status\": \"$STATUS\"}" || true

Step 5: Add Vigilmon monitors

Log in to Vigilmon and go to Monitors → Add Monitor.

Workload health monitor (per environment):

| Field | Value | |-------|-------| | Monitor type | HTTP | | Name | Score workload — api-service (staging) | | URL | https://api.staging.your-domain.com/health | | Check interval | 1 minute | | HTTP method | GET | | Expected status | 200 | | Response must contain | "status":"ok" |

Repeat for each environment (staging, production, DR):

| Field | Value | |-------|-------| | Monitor type | HTTP | | Name | Score workload — api-service (production) | | URL | https://api.your-domain.com/health | | Check interval | 1 minute | | Response must contain | "status":"ok" |

Translation health monitor:

| Field | Value | |-------|-------| | Monitor type | HTTP | | Name | Score — translation validation | | URL | http://<your-health-server>:9400/health/translation | | Check interval | 10 minutes | | Expected status | 200 | | Response must contain | "status":"ok" |

Deployment health monitor:

| Field | Value | |-------|-------| | Monitor type | HTTP | | Name | Score — deployment pipeline | | URL | http://<your-health-server>:9400/health/deployment | | Check interval | 10 minutes | | Expected status | 200 | | Response must contain | "status":"ok" |


Step 6: Monitor environment parity

Compare health responses across environments to catch parity drift:

# parity-check/app.py
import requests
from flask import Flask, jsonify
import os

app = Flask(__name__)

ENVIRONMENTS = {
    "staging": os.environ.get("STAGING_HEALTH_URL", "https://api.staging.example.com/health"),
    "production": os.environ.get("PROD_HEALTH_URL", "https://api.example.com/health"),
}

@app.route("/health/parity")
def parity_check():
    results = {}
    for env, url in ENVIRONMENTS.items():
        try:
            resp = requests.get(url, timeout=10)
            data = resp.json()
            results[env] = {
                "status": data.get("status"),
                "checks": data.get("checks", {}),
                "http_status": resp.status_code,
            }
        except Exception as e:
            results[env] = {"status": "error", "detail": str(e)}

    # Check if all environments agree on status
    statuses = {env: r["status"] for env, r in results.items()}
    all_ok = all(s == "ok" for s in statuses.values())

    if not all_ok:
        return jsonify({
            "status": "parity_failure",
            "environments": results,
        }), 503

    return jsonify({
        "status": "ok",
        "environments": list(results.keys()),
    })

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=9401)

Add a parity monitor in Vigilmon:

| Field | Value | |-------|-------| | Monitor type | HTTP | | Name | Score — staging/production parity | | URL | http://<your-health-server>:9401/health/parity | | Check interval | 5 minutes | | Expected status | 200 | | Response must contain | "status":"ok" |


Step 7: Configure alerts

In Vigilmon, go to Alerts → Alert Channels and connect your notification channels (email, Slack, PagerDuty, webhook).

Apply alert policies to each Score monitor:

| Monitor | Alert after | Notes | |---------|------------|-------| | Workload health (production) | 1 failure | Production failures alert immediately | | Workload health (staging) | 2 failures | Brief staging blips are normal | | Translation health | 1 failure | Translation failures block all deployments | | Deployment health | 1 failure | Deploy failures need immediate investigation | | Parity check | 2 failures | Allow one transient divergence |

Enable recovery alerts on all monitors so you know when a degraded environment recovers.


What you're monitoring now

| Monitor | What it detects | |---------|-----------------| | Workload health HTTP (per env) | Application failures, resource provisioner errors, crashes | | Translation health HTTP | Score-to-platform translation failures, field drops | | Deployment health HTTP | Pipeline failures, rollout timeouts, stale deploys | | Parity check HTTP | Environment divergence, staging/prod behavioral differences |


Conclusion

Score's platform-agnostic design is its strength, but that abstraction layer is also where drift hides. A workload that translates successfully and deploys without errors can still silently behave differently across environments when provisioner outputs change, platform extensions diverge, or Kubernetes upgrades alter how generated manifests are applied. Vigilmon monitors the actual runtime behavior of your Score-managed workloads — what the service returns to users in each environment — and flags divergence that the Score toolchain can't see.

Sign up for a free Vigilmon account and add your first Score workload monitor today.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →