Deployment Verification
After a fresh deploy or an update, run these commands on the server to confirm every service is healthy and all seed data was provisioned correctly.
All commands assume you are logged in to the server via SSH and working from the deployment directory:
cd /opt/ctms-deployment
The base Docker Compose alias used throughout:
DC="docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.production"
1. Service Health Checks
Verify all 10 platform services respond with the expected HTTP status code.
echo "=== Service Health ==="
for EP in \
"Zynexa|http://127.0.0.1:3000" \
"API-Gateway|http://127.0.0.1:9080/__health" \
"Sublink|http://127.0.0.1:3001" \
"Cube|http://127.0.0.1:4000/readyz" \
"MCP|http://127.0.0.1:8006/health" \
"ODM|http://127.0.0.1:8001/health" \
"OpenObserve|http://127.0.0.1:5080/healthz" \
"Grafana|http://127.0.0.1:3100/api/health" \
"Elementary|http://127.0.0.1:3200/health" \
"Frappe|http://127.0.0.1:8080"
do
NAME=$(echo "$EP" | cut -d"|" -f1)
URL=$(echo "$EP" | cut -d"|" -f2)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$URL" 2>/dev/null)
if [[ "$STATUS" =~ ^(200|301|302|307)$ ]]; then
echo " ✅ $NAME: $STATUS"
else
echo " ❌ $NAME: $STATUS"
fi
done
Expected Results
| Service | Endpoint | Expected |
|---|---|---|
| Zynexa | :3000 | 307 (redirect to login) |
| API-Gateway | :9080/__health | 200 |
| Sublink | :3001 | 200 |
| Cube | :4000/readyz | 200 |
| MCP | :8006/health | 200 |
| ODM | :8001/health | 200 |
| OpenObserve | :5080/healthz | 200 |
| Grafana | :3100/api/health | 200 |
| Elementary | :3200/health | 200 |
| Frappe | :8080 | 200 |
2. Frappe API Token
Extract the Frappe API token from the environment file. This token is used for all subsequent Frappe API calls.
TOKEN=$(grep "^FRAPPE_API_TOKEN=" /opt/ctms-deployment/.env.production | cut -d= -f2)
echo "Token: ${TOKEN}"
If the token is empty, see the Frappe Token Refresh recipe to regenerate it.
3. Frappe Seed Data Verification
Verify that all master/reference data was seeded correctly across the 5 ctms-init stages.
Item Groups
Two Item Groups — Laboratory and Drug — must exist before any Items can be created (added in bundle v2.30).
TOKEN=$(grep "^FRAPPE_API_TOKEN=" /opt/ctms-deployment/.env.production | cut -d= -f2)
echo "--- Item Groups ---"
curl -s -H "Authorization: token $TOKEN" \
"http://127.0.0.1:8080/api/resource/Item%20Group?limit_page_length=0" \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
for item in data['data']:
print(f' {item[\"name\"]}')"
Expected: At minimum All Item Groups, Laboratory, Drug.
Sites
TOKEN=$(grep "^FRAPPE_API_TOKEN=" /opt/ctms-deployment/.env.production | cut -d= -f2)
echo "--- Sites ---"
curl -s -H "Authorization: token $TOKEN" \
"http://127.0.0.1:8080/api/resource/Site?limit_page_length=0" \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
print(f' Count: {len(data[\"data\"])}')"
Expected: 2 sites.
Study Events
TOKEN=$(grep "^FRAPPE_API_TOKEN=" /opt/ctms-deployment/.env.production | cut -d= -f2)
echo "--- Study Events ---"
curl -s -H "Authorization: token $TOKEN" \
"http://127.0.0.1:8080/api/resource/Study%20Event?limit_page_length=0" \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
print(f' Count: {len(data[\"data\"])}')"
Expected: 17 study events.
Items (Laboratory)
TOKEN=$(grep "^FRAPPE_API_TOKEN=" /opt/ctms-deployment/.env.production | cut -d= -f2)
echo "--- Items ---"
curl -s -H "Authorization: token $TOKEN" \
"http://127.0.0.1:8080/api/resource/Item?limit_page_length=0" \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
print(f' Count: {len(data[\"data\"])}')"
Expected: 4 items.
Drug Master
TOKEN=$(grep "^FRAPPE_API_TOKEN=" /opt/ctms-deployment/.env.production | cut -d= -f2)
echo "--- Drug Master ---"
curl -s -H "Authorization: token $TOKEN" \
"http://127.0.0.1:8080/api/resource/zynomi_drug_master?limit_page_length=0" \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
print(f' Count: {len(data[\"data\"])}')"
Expected: 52 drugs.
Healthcare Practitioner
TOKEN=$(grep "^FRAPPE_API_TOKEN=" /opt/ctms-deployment/.env.production | cut -d= -f2)
echo "--- Healthcare Practitioner ---"
curl -s -H "Authorization: token $TOKEN" \
"http://127.0.0.1:8080/api/resource/Healthcare%20Practitioner?limit_page_length=0" \
| python3 -c "
import json, sys
data = json.load(sys.stdin)
for p in data['data']:
print(f' {p[\"name\"]}')"
Expected: At least HLC-PRAC-2026-00001.
4. Deploy Log Analysis
If you ran the deploy via zynctl.sh, check the log file for critical milestones.
Step 6b — Setup Container Wait
grep -A2 "Waiting for Frappe setup" /tmp/deploy-*.log
Expected: ✅ Frappe setup container completed successfully.
Step 7 — Token Extraction
grep "FRAPPE_API_TOKEN" /tmp/deploy-*.log | head -3
Expected: A line showing the token was found and written to .env.production.
Step 8 — ctms-init Stages
grep -E "Stage [1-5]|✅|❌|PASS|FAIL" /tmp/deploy-*.log
Expected: All 5 stages should show ✅ or PASS.
5. Docker Container Status
Quick overview of all running containers and their health.
echo "=== Running Containers ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | sort
echo ""
echo "=== Container Counts ==="
echo "Running: $(docker ps -q | wc -l) Stopped: $(docker ps -aq --filter status=exited | wc -l) Total: $(docker ps -aq | wc -l)"
6. Vendor Stack Health
Supabase
echo "--- Supabase ---"
curl -s -o /dev/null -w " Auth: %{http_code}\n" --max-time 3 "http://127.0.0.1:9999/health"
curl -s -o /dev/null -w " REST: %{http_code}\n" --max-time 3 "http://127.0.0.1:3456/"
curl -s -o /dev/null -w " Studio: %{http_code}\n" --max-time 3 "http://127.0.0.1:8443/"
Frappe Direct
echo "--- Frappe Backend ---"
docker exec frappe-marley-health-backend-1 bench --site frontend list-apps
7. Full Verification Script
Copy-paste this single script to run all checks at once:
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────
# CTMS Deployment Verification Script
# Run on the server after deploy or update
# ──────────────────────────────────────────────────────────
set -euo pipefail
cd /opt/ctms-deployment
TOKEN=$(grep "^FRAPPE_API_TOKEN=" .env.production | cut -d= -f2)
FRAPPE="http://127.0.0.1:8080"
echo "╔══════════════════════════════════════════════════════╗"
echo "║ CTMS Deployment Verification ║"
echo "╚══════════════════════════════════════════════════════╝"
echo ""
# ── 1. Service Health ───────────────────────────────────────
echo "━━━ 1. Service Health ━━━"
PASS=0; FAIL=0
for EP in \
"Zynexa|http://127.0.0.1:3000" \
"API-Gateway|http://127.0.0.1:9080/__health" \
"Sublink|http://127.0.0.1:3001" \
"Cube|http://127.0.0.1:4000/readyz" \
"MCP|http://127.0.0.1:8006/health" \
"ODM|http://127.0.0.1:8001/health" \
"OpenObserve|http://127.0.0.1:5080/healthz" \
"Grafana|http://127.0.0.1:3100/api/health" \
"Elementary|http://127.0.0.1:3200/health" \
"Frappe|http://127.0.0.1:8080"
do
NAME=$(echo "$EP" | cut -d"|" -f1)
URL=$(echo "$EP" | cut -d"|" -f2)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$URL" 2>/dev/null)
if [[ "$STATUS" =~ ^(200|301|302|307)$ ]]; then
echo " ✅ $NAME ($STATUS)"
((PASS++))
else
echo " ❌ $NAME ($STATUS)"
((FAIL++))
fi
done
echo " ── $PASS passed, $FAIL failed"
echo ""
# ── 2. Frappe Token ─────────────────────────────────────────
echo "━━━ 2. Frappe API Token ━━━"
if [ -z "$TOKEN" ]; then
echo " ❌ Token not found in .env.production"
else
echo " ✅ Token: ${TOKEN:0:20}…"
fi
echo ""
# ── 3. Frappe Seed Data ─────────────────────────────────────
echo "━━━ 3. Frappe Seed Data ━━━"
check_doctype() {
local label=$1 doctype=$2 expected=$3
local count
count=$(curl -s -H "Authorization: token $TOKEN" \
"$FRAPPE/api/resource/$doctype?limit_page_length=0" \
| python3 -c "import json,sys; print(len(json.load(sys.stdin)['data']))" 2>/dev/null || echo "ERR")
if [ "$count" = "ERR" ]; then
echo " ❌ $label: API error"
elif [ "$count" -ge "$expected" ]; then
echo " ✅ $label: $count (expected ≥$expected)"
else
echo " ⚠️ $label: $count (expected ≥$expected)"
fi
}
check_doctype "Item Groups" "Item%20Group" 3
check_doctype "Sites" "Site" 2
check_doctype "Study Events" "Study%20Event" 17
check_doctype "Items" "Item" 4
check_doctype "Drug Master" "zynomi_drug_master" 52
check_doctype "Healthcare Practitioner" "Healthcare%20Practitioner" 1
echo ""
# ── 4. Container Status ────────────────────────────────────
echo "━━━ 4. Container Status ━━━"
RUNNING=$(docker ps -q | wc -l | tr -d ' ')
STOPPED=$(docker ps -aq --filter status=exited | wc -l | tr -d ' ')
echo " Running: $RUNNING | Stopped: $STOPPED | Total: $((RUNNING + STOPPED))"
echo ""
# ── 5. Deploy Log (if available) ───────────────────────────
LOGFILE=$(ls -t /tmp/deploy-*.log 2>/dev/null | head -1)
if [ -n "$LOGFILE" ]; then
echo "━━━ 5. Deploy Log ($LOGFILE) ━━━"
if grep -q "Waiting for Frappe setup" "$LOGFILE"; then
echo " ✅ Step 6b: Setup container wait detected"
else
echo " ⚠️ Step 6b: Not found in log (older bundle?)"
fi
if grep -q "FRAPPE_API_TOKEN" "$LOGFILE"; then
echo " ✅ Step 7: Token extraction detected"
else
echo " ⚠️ Step 7: Not found in log"
fi
STAGES_PASS=$(grep -c "✅" "$LOGFILE" 2>/dev/null || echo 0)
echo " ℹ️ Log contains $STAGES_PASS checkmarks"
else
echo "━━━ 5. Deploy Log ━━━"
echo " ℹ️ No deploy log found in /tmp/"
fi
echo ""
# ── Summary ─────────────────────────────────────────────────
echo "╔══════════════════════════════════════════════════════╗"
echo "║ Verification complete ║"
echo "╚══════════════════════════════════════════════════════╝"
You can save the full script to your server for repeated use:
curl -sL https://raw.githubusercontent.com/zynomi/ctms.devops/main/scripts/verify-deployment.sh \
-o /usr/local/bin/ctms-verify && chmod +x /usr/local/bin/ctms-verify
Then simply run ctms-verify after every deploy or update.
8. Manual Provisioning (Docker Compose)
If any provisioning step was skipped during deploy, or you need to re-run them on an existing deployment, use the commands below in this order. All commands use Docker Compose — no direct Frappe API calls or token passing required.
All services must already be running (docker ps should show Zynexa, Frappe, Supabase, etc. as healthy). These are not destructive — they do not restart or recreate the Zynexa container.
cd /opt/ctms-deployment
DC="docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.production"
Step 1 — Healthcare Practitioner (ctms-init Stage 5)
Creates the default Healthcare Practitioner record in Frappe (e.g. HLC-PRAC-2026-00001). This runs inside the ctms-init container which already has the Frappe API token from .env.production.
# Run only Stage 5 (practitioner)
CTMS_INIT_STAGES=5 $DC --profile init run --rm ctms-init
Expected output: Prints the practitioner ID and instructions to set NEXT_PUBLIC_DEFAULT_PRACTITIONER_ID in .env.production.
| Option | Command |
|---|---|
| Dry run (preview only) | CTMS_INIT_DRY_RUN=true CTMS_INIT_STAGES=5 $DC --profile init run --rm ctms-init |
| Re-run all 5 stages | $DC --profile init run --rm ctms-init |
| Force-pull latest image first | $DC --profile init pull ctms-init (then run above) |
If a practitioner with the same name already exists, the command skips creation and prints the existing ID.
Step 2 — Seed Demo Users
Creates 4 demo staff users via the Zynexa signup API. Each user gets a Supabase Auth account + Frappe User record.
$DC --profile init run --rm ctms-user-seed
| Role | Password | |
|---|---|---|
kiran.v@zynomi.com | Platform Administrator | Welcome@1234 |
michael.x@zynomi.com | Study Coordinator | Welcome@1234 |
roshini.s@zynomi.com | Study Designer | Welcome@1234 |
peter.p@zynomi.com | Principal Investigator | Welcome@1234 |
Expected output: Done: 4 total | ✅ N created | ⏭️ N skipped
Existing users (HTTP 409/422) are counted as skipped — safe to re-run.
Step 3 — Seed Demo Patients
Creates 20 synthetic patient accounts. Each patient gets: Supabase Auth → Frappe User → Frappe Patient record.
$DC --profile init run --rm ctms-patient-seed
| Option | Command |
|---|---|
| Dry run (preview payloads) | DRY_RUN=true $DC --profile init run --rm ctms-patient-seed |
Expected output: Done: 20 total | ✅ N created | ⏭️ N skipped | ❌ N failed
All patients use @subject.com emails with password Welcome@1234.
Duplicate patients (HTTP 409/422) are skipped. Safe to re-run after partial failures.
Run All Three in Sequence
cd /opt/ctms-deployment
DC="docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env.production"
# 1. Healthcare Practitioner
CTMS_INIT_STAGES=5 $DC --profile init run --rm ctms-init
# 2. Demo Users (4 staff accounts)
$DC --profile init run --rm ctms-user-seed
# 3. Demo Patients (20 synthetic patients)
$DC --profile init run --rm ctms-patient-seed
The practitioner must exist before patients can be assigned to studies. Demo users should be created before patients so coordinators can manage them.
For advanced options (selective stages, environment variables, alternative scripts), see the Platform Provisioning Commands recipe.
Quick Reference
| Check | Command | Expected |
|---|---|---|
| All services healthy | Section 1 | 10/10 pass |
| Token present | Section 2 | Non-empty string |
| Item Groups exist | Section 3 | ≥3 (incl. Laboratory, Drug) |
| Sites seeded | Section 3 | ≥2 |
| Study Events seeded | Section 3 | ≥17 |
| Items created | Section 3 | ≥4 |
| Drug Master loaded | Section 3 | ≥52 |
| Practitioner exists | Section 3 | ≥1 |
| Demo users created | Section 8 | 4 staff users |
| Demo patients created | Section 8 | 20 patients |
| Deploy log clean | Section 4 | All stages ✅ |
See Also
- Platform Runbook — operational commands for day-to-day management
- Debugging & Troubleshooting — common error resolution
- Frappe Token Refresh — fix 401 errors after restart
- Seed & Init Commands — re-run individual provisioning stages