Commerce hosting — design + architecture specs
Commerce hosting — design + architecture specs
Young, 2026-10-13: "Package 3 products into single package as sell services."
This document is the engineering design and architecture spec for the per-tenant hosted commerce product. It covers the tenant template, secrets model, EVE agent template, POS integration, multi-env topology, and the update/rollback pipeline.
Inputs: Tam's four verified studies (live-probed 2026-10-13), Min's deep dive, Mira's business plan. This spec does not re-derive their measurements — it builds on them. Every architectural decision traces to a measured fact in those documents.
Status: DESIGN. No build exists. This is the spec Ace builds from.
1. The product in one paragraph
One sellable package per tenant: Medusa v2 backend + Next.js storefront + Agilo POS + EVE agent, deployed as a per-tenant Docker compose project on Oracle ARM. The customer gets a hosted online shop, in-store POS on their tablets, and an AI staff member that answers customers — one monthly price, we run all of it. The AI-staff layer is the differentiator: neither Medusa Cloud nor Shopify bundles it.
Licence profile: MIT (Medusa + dtc-starter + Agilo POS) + Apache-2.0 (EVE) — clean end-to-end, no competing-use restriction, no per-seat metering.
2. Architecture overview
┌─────────────────────────────────────┐
│ Oracle ARM node (aarch64 × 4 OCPU) │
│ 23 Gi RAM · 180 G disk free │
└──────────────┬──────────────────────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
┌──────────▼──────────┐ ┌─────────▼─────────┐ ┌──────────▼──────────┐
│ tenant-a (compose) │ │ tenant-b (compose)│ │ tenant-c (compose) │
│ ────────────────── │ │ ──────────────── │ │ ────────────────── │
│ postgres:15-alpine │ │ postgres:15-alpine│ │ postgres:15-alpine │
│ redis:7-alpine │ │ redis:7-alpine │ │ redis:7-alpine │
│ medusa server │ │ medusa server │ │ medusa server │
│ medusa worker │ │ medusa worker │ │ medusa worker │
│ storefront (Next.js)│ │ storefront │ │ storefront (Next.js)│
│ eve agent (Nitro) │ │ eve agent │ │ eve agent (Nitro) │
│ ────────────────── │ │ ──────────────── │ │ ────────────────── │
│ 4.8 GB steady │ │ 4.8 GB steady │ │ 4.8 GB steady │
└──────────────────────┘ └────────────────────┘ └──────────────────────┘
│ │ │
└──────────────────────┼──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ nginx reverse proxy (per-tenant │
│ domain + TLS termination) │
└─────────────────────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Agilo POS (client tablets) │
│ → Admin REST API over HTTPS │
└─────────────────────────────────────┘
Per-tenant isolation is the security model. Each tenant is a separate compose
project with its own Postgres volume, Redis container, containers, and secrets
file. No 0.0.0.0 binds — all services bind to 127.0.0.1 inside the compose
network; only the nginx proxy exposes ports, and only to the tenant's domain.
Capacity: ~4 full tenant stacks (4.8 GB each) or ~6 small tuned stacks (3 GB each) per 23 Gi ARM node. Pilot target: 2–3 tenants on the existing node.
3. Acceptance criteria — design specs
3.1 Tenant template repo
Base: fork medusajs/dtc-starter (MIT, active, the official Medusa v2 starter).
The fork is the single template — one repo, version-tagged, all tenants pull
from it.
Repo structure:
dtc-starter/ ← forked template repo
├── Dockerfile ← multi-stage prod build (server + worker)
├── docker-compose.prod.yml ← server/worker split, per-tenant env
├── docker-compose.staging.yml ← staging variant (same image tag)
├── scripts/
│ ├── onboarding.sh ← one-command tenant provisioning
│ ├── cutover.sh ← /health-gated production cutover
│ ├── update.sh ← git-tag update pipeline
│ └── rollback.sh ← prior-tag rollback
├── .env.example ← template (no real secrets)
├── .env.secrets.register ← per-tenant secrets (0600, gitignored)
├── medusa/
│ ├── package.json
│ ├── medusa-config.ts
│ └── src/ ← custom modules (services catalogue later)
├── storefront/
│ ├── package.json
│ └── src/
└── eve/
├── agent/
│ ├── instructions.md
│ ├── tools/
│ ├── connections/
│ ├── skills/
│ └── workflows/
├── package.json
└── eve.config.ts
Dockerfile (multi-stage, prod):
# ---- Stage 1: build ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm medusa build
# ---- Stage 2: server ----
FROM node:20-alpine AS server
WORKDIR /app
COPY --from=builder /app/.medusa ./.medusa
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
ENV MEDUSA_WORKER_MODE=server
ENV NODE_ENV=production
EXPOSE 9000
CMD ["sh", "-c", "cd .medusa/server && npm install --omit=dev && npm run predeploy && npm run start"]
# ---- Stage 3: worker ----
FROM node:20-alpine AS worker
WORKDIR /app
COPY --from=builder /app/.medusa ./.medusa
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
ENV MEDUSA_WORKER_MODE=worker
ENV NODE_ENV=production
EXPOSE 9000
CMD ["sh", "-c", "cd .medusa/server && npm install --omit=dev && npm run start"]
docker-compose.prod.yml:
version: "3.9"
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5
networks: [tenant-net]
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks: [tenant-net]
medusa-server:
build:
context: .
target: server
env_file: .env.secrets.register
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
MEDUSA_WORKER_MODE: server
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9000/health"]
interval: 10s
timeout: 5s
retries: 12
networks: [tenant-net]
medusa-worker:
build:
context: .
target: worker
env_file: .env.secrets.register
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
MEDUSA_WORKER_MODE: worker
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
networks: [tenant-net]
storefront:
image: node:20-alpine
working_dir: /app
command: sh -c "npm install --omit=dev && npm run start"
environment:
MEDUSA_BACKEND_URL: http://medusa-server:9000
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY: ${MEDUSA_PUBLISHABLE_KEY}
depends_on:
medusa-server: { condition: service_healthy }
networks: [tenant-net]
eve-agent:
image: node:20-alpine
working_dir: /app
command: sh -c "npm install --omit=dev && npx eve start"
env_file: .env.secrets.register
environment:
MEDUSA_ADMIN_URL: http://medusa-server:9000
MEDUSA_ADMIN_TOKEN: ${MEDUSA_ADMIN_TOKEN}
depends_on:
medusa-server: { condition: service_healthy }
networks: [tenant-net]
volumes:
postgres_data:
redis_data:
networks:
tenant-net:
internal: true
Key design decisions:
- Server/worker split:
MEDUSA_WORKER_MODE=serveron the server container,workeron the worker. Admin UI disabled on the worker (DISABLE_MEDUSA_ADMIN=true). Shared Postgres + Redis. This is the production topology from Medusa's docs. - Internal network:
tenant-netisinternal: true— no container exposes a host port. Only nginx (outside the compose project) reaches the services. - Health-gated startup: every service has a healthcheck;
depends_onusescondition: service_healthyso the stack comes up in order. - Shared image, per-tenant env: the Dockerfile is built once per template
release. Tenants differ only in their
.env.secrets.registerand compose project name. This is the "build once, deploy many" model from Tam's study.
Onboarding script (scripts/onboarding.sh):
#!/usr/bin/env bash
# One-command tenant provisioning.
# Usage: ./scripts/onboarding.sh <tenant-name> <domain> [staging|production]
set -euo pipefail
TENANT_NAME="${1:?tenant name required}"
DOMAIN="${2:?domain required}"
ENV="${3:-production}"
COMPOSE_PROJECT="dtc-${TENANT_NAME}-${ENV}"
echo "=== Onboarding tenant: ${TENANT_NAME} (${ENV}) ==="
# 1. Clone template (shallow, latest tag)
git clone --depth 1 --branch "${TEMPLATE_TAG:-main}" \
"${TEMPLATE_REPO}" "/srv/dtc/${TENANT_NAME}-${ENV}"
cd "/srv/dtc/${TENANT_NAME}-${ENV}"
# 2. Generate secrets register (0600, never in compose)
SECRETS_FILE=".env.secrets.register"
if [ -f "$SECRETS_FILE" ](--f-"$secrets_file"-); then
echo "ERROR: secrets file already exists. Refusing to overwrite."
exit 1
fi
{
echo "# Auto-generated by onboarding.sh — DO NOT COMMIT"
echo "# $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "POSTGRES_USER=medusa_${TENANT_NAME}"
echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)"
echo "POSTGRES_DB=medusa_${TENANT_NAME}"
echo "REDIS_PASSWORD=$(openssl rand -hex 32)"
echo "JWT_SECRET=$(openssl rand -hex 32)"
echo "COOKIE_SECRET=$(openssl rand -hex 32)"
echo "MEDUSA_PUBLISHABLE_KEY=$(openssl rand -hex 16)"
echo "MEDUSA_ADMIN_TOKEN=$(openssl rand -hex 32)"
echo "EVE_API_KEY=$(openssl rand -hex 32)"
} > "$SECRETS_FILE"
chmod 600 "$SECRETS_FILE"
# 3. Create compose project
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml up -d
# 4. Wait for health
echo "Waiting for Medusa /health..."
for i in $(seq 1 30); do
if docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml \
exec -T medusa-server wget -q -O /dev/null http://127.0.0.1:9000/health; then
echo "Medusa healthy."
break
fi
sleep 2
done
# 5. Create admin user
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml \
exec -T medusa-server npx medusa user \
-e "admin@${DOMAIN}" \
-p "$(openssl rand -hex 12)"
# 6. Configure nginx (per-tenant domain + TLS)
# (delegated to proxy config — see §3.5)
echo "=== Tenant ${TENANT_NAME} provisioned ==="
echo " Domain: ${DOMAIN}"
echo " Compose project: ${COMPOSE_PROJECT}"
echo " Secrets: ${SECRETS_FILE} (0600)"
echo " Admin: admin@${DOMAIN} (password in secrets file)"
Cutover script (scripts/cutover.sh):
#!/usr/bin/env bash
# /health-gated production cutover.
# Usage: ./scripts/cutover.sh <tenant-name>
# Swaps nginx from staging to production after both health checks pass.
set -euo pipefail
TENANT_NAME="${1:?tenant name required}"
STAGING_PROJECT="dtc-${TENANT_NAME}-staging"
PROD_PROJECT="dtc-${TENANT_NAME}-production"
echo "=== Cutover: ${TENANT_NAME} ==="
# 1. Verify staging health
echo "Checking staging health..."
docker compose -p "$STAGING_PROJECT" -f docker-compose.prod.yml \
exec -T medusa-server wget -q -O /dev/null http://127.0.0.1:9000/health
docker compose -p "$STAGING_PROJECT" -f docker-compose.prod.yml \
exec -T eve-agent wget -q -O /dev/null http://127.0.0.1:3000/eve/v1/health
echo "Staging healthy."
# 2. Verify production health
echo "Checking production health..."
docker compose -p "$PROD_PROJECT" -f docker-compose.prod.yml \
exec -T medusa-server wget -q -O /dev/null http://127.0.0.1:9000/health
docker compose -p "$PROD_PROJECT" -f docker-compose.prod.yml \
exec -T eve-agent wget -q -O /dev/null http://127.0.0.1:3000/eve/v1/health
echo "Production healthy."
# 3. Swap nginx upstream
# (delegated to proxy config — see §3.5)
echo "=== Cutover complete: ${TENANT_NAME} ==="
3.2 Per-tenant secrets register
Model: one .env.secrets.register file per tenant, per environment. Generated
by onboarding.sh. Never committed to git. Never embedded in compose files.
Rules:
| Rule | Rationale |
|---|---|
chmod 600 on the secrets file | only the tenant's containers read it |
env_file: .env.secrets.register in compose | Docker reads it directly; no shell interpolation |
openssl rand -hex 32 for all secrets | 256-bit entropy, no external dependency |
.env.secrets.register in .gitignore | never committed, never pushed |
.env.example in git | template with dummy values, safe to commit |
| Secrets never in compose YAML | compose files are version-controlled; secrets are not |
Secrets never in environment variables passed to docker compose | shell history, process table exposure |
Secrets inventory:
| Secret | Purpose | Generated |
|---|---|---|
POSTGRES_USER | Postgres username | medusa_<tenant> |
POSTGRES_PASSWORD | Postgres password | openssl rand -hex 32 |
POSTGRES_DB | Postgres database name | medusa_<tenant> |
REDIS_PASSWORD | Redis AUTH password | openssl rand -hex 32 |
JWT_SECRET | Medusa JWT signing | openssl rand -hex 32 |
COOKIE_SECRET | Medusa cookie signing | openssl rand -hex 32 |
MEDUSA_PUBLISHABLE_KEY | Storefront publishable key (read-scope) | openssl rand -hex 16 |
MEDUSA_ADMIN_TOKEN | Admin API token for EVE agent HIL workflows | openssl rand -hex 32 |
EVE_API_KEY | EVE agent API key | openssl rand -hex 32 |
Key scoping:
- Storefront agent tools use
MEDUSA_PUBLISHABLE_KEY(read-scope) — can read products, carts, orders but cannot mutate. - EVE agent HIL workflows use
MEDUSA_ADMIN_TOKEN(write-scope) — only invoked after human approval through the HIL gate. - Admin UI uses the admin user credentials created during onboarding.
3.3 EVE agent template
Base: eve init scaffold (Apache-2.0, beta, filesystem-first). Self-hosted as
a Nitro server beside the tenant's Medusa stack.
Agent structure:
eve/
├── agent/
│ ├── instructions.md ← sales/support persona, tenant-tuned
│ ├── tools/
│ │ ├── products.ts ← GET /store/products (read)
│ │ ├── carts.ts ← GET /store/carts (read)
│ │ ├── checkout.ts ← POST /store/carts/:id/complete (write, HIL-gated)
│ │ └── order-status.ts ← GET /store/orders/:id (read)
│ ├── connections/
│ │ └── medusa.ts ← Medusa Admin API connection (zod-validated config)
│ ├── skills/
│ │ ├── refund-playbook.md ← refund policy + approval workflow
│ │ └── checkout-flow.md ← checkout assistance + common issues
│ └── workflows/
│ ├── order-status-watcher.ts ← durable: poll order status, notify customer
│ └── hil-refund-gate.ts ← HIL: pause → admin approval → execute
├── package.json
└── eve.config.ts
connections/medusa.ts:
import { defineDynamic } from "@eve/sdk/connections";
import { z } from "zod";
export default defineDynamic({
name: "medusa",
configSchema: z.object({
MEDUSA_ADMIN_URL: z.string().url(),
MEDUSA_ADMIN_TOKEN: z.string().min(1),
}),
// Secrets come from environment (0600 fleet hygiene carries).
// Never stored in source, never in compose files.
});
tools/products.ts (read-scope):
import { defineTool } from "@eve/sdk/tools";
export default defineTool({
name: "list_products",
description: "List available products with pricing and inventory",
parameters: z.object({
limit: z.number().optional().default(20),
offset: z.number().optional().default(0),
}),
// Uses MEDUSA_PUBLISHABLE_KEY (read-scope)
async execute({ limit, offset }, { connections }) {
const medusa = await connections.medusa();
return medusa.store.products.list({ limit, offset });
},
});
workflows/hil-refund-gate.ts (HIL skeleton):
import { defineWorkflow } from "@eve/sdk/workflows";
export default defineWorkflow({
name: "hil-refund-gate",
description: "Process refund with human-in-the-loop approval",
// HIL: pause → admin approval → execute against Admin API
// Uses MEDUSA_ADMIN_TOKEN (write-scope, HIL-gated)
async execute({ orderId, amount, reason }, { connections, human }) {
// 1. Pause and request human approval
const approval = await human.approve({
action: "refund",
orderId,
amount,
reason,
});
if (!approval.granted) {
return { status: "rejected", reason: approval.reason };
}
// 2. Execute refund via Admin API
const medusa = await connections.medusa();
return medusa.admin.refunds.create({ orderId, amount, reason });
},
});
Version pinning:
{
"dependencies": {
"@eve/sdk": "5.0.0-beta.2",
"@workflow/core": "5.0.0-beta.2",
"@workflow/runtime": "5.0.0-beta.2"
}
}
EVE beta risk mitigation: pin to exact versions (not ranges). The update
pipeline includes a per-tenant EVE health check (/eve/v1/health) before the tenant
is marked healthy. If EVE breaks between pinned versions, the tenant stays on the
last known-good version until a fix is validated in staging.
3.4 POS integration
Component: Agilo POS (Agilo/medusa-pos-starter, MIT, Expo React Native).
Architecture: Agilo POS is a client-side app running on the tenant's tablets. It connects to the tenant's Medusa Admin REST API over HTTPS. No backend changes required — the POS is a consumer of the same Admin API that the EVE agent uses.
Agilo POS (tablet)
│
│ HTTPS → Admin REST API
│ (admin credentials from POS setup wizard)
│
▼
Medusa server (tenant compose project)
│
├── /admin/auth/login
├── /admin/orders (draft orders)
├── /admin/products
└── /admin/customers
Setup flow:
- Tenant installs Agilo POS on their tablets (APK or Expo Go).
- POS setup wizard: enter tenant's Medusa Admin URL + admin credentials.
- POS fetches regions, sales channels, stock locations from Admin API.
- Staff log in with admin credentials (per-staff credentials are a future add — the current model uses one admin credential per tenant, which is coarse but functional for pilot).
- POS creates draft orders → sync to Admin → appear in Medusa admin alongside online orders.
Manual payment path (documented):
Agilo POS has no integrated payment processing (confirmed in Tam's study, MVP gap). The manual payment plugin path exists in Medusa:
- POS creates a draft order with items.
- At checkout, staff select "Manual Payment" as the payment method.
- Staff collect payment externally (cash, bank transfer, terminal).
- Staff mark the order as paid in Medusa admin.
- Order moves from draft to confirmed.
This is the pilot payment path. Terminal integrations (Stripe/Adyen) are on Agilo's roadmap but not yet available. The pilot sells "website + AI staff" with POS as a Phase 2 add — the AI staff layer is the differentiator, not POS payments.
POS auth model (current):
| Aspect | Current | Future |
|---|---|---|
| Credentials | One admin credential per tenant | Per-staff roles |
| Auth | Admin REST API token | Staff-scoped tokens |
| Payment | Manual (cash/invoice) | Terminal integration |
| Offline | No (cached browse only) | Full offline mode |
3.5 Multi-env topology
Model: staging + production per client = two compose projects, same image tag, separate DBs/Redis.
tenant-a-staging/ ← compose project: dtc-tenant-a-staging
├── postgres (staging DB)
├── redis
├── medusa server
├── medusa worker
├── storefront
└── eve agent
tenant-a-production/ ← compose project: dtc-tenant-a-production
├── postgres (production DB)
├── redis
├── medusa server
├── medusa worker
├── storefront
└── eve agent
Same image tag: both environments pull the same Docker image tag from the
template repo. The only difference is the .env.secrets.register (different
secrets per environment) and the compose project name.
nginx routing:
# staging
server {
listen 443 ssl;
server_name staging.<tenant-domain>.com;
location / {
proxy_pass http://127.0.0.1:8001; # staging storefront
}
location /eve/ {
proxy_pass http://127.0.0.1:3001/; # staging eve agent
}
location /.well-known/workflow/ {
proxy_pass http://127.0.0.1:3001; # staging eve workflows
}
}
# production
server {
listen 443 ssl;
server_name <tenant-domain>.com;
location / {
proxy_pass http://127.0.0.1:8000; # production storefront
}
location /eve/ {
proxy_pass http://127.0.0.1:3000/; # production eve agent
}
location /.well-known/workflow/ {
proxy_pass http://127.0.0.1:3000; # production eve workflows
}
}
Cutover: cutover.sh verifies both staging and production health, then swaps
the nginx upstream from staging to production. Rollback = swap back.
3.6 Update pipeline
Model: git-tagged template → pull → predeploy (auto-migrate) → restart → health re-check. Rollback = prior tag.
Update script (scripts/update.sh):
#!/usr/bin/env bash
# Git-tag update pipeline.
# Usage: ./scripts/update.sh <tenant-name> <env> <new-tag>
set -euo pipefail
TENANT_NAME="${1:?tenant name required}"
ENV="${2:?environment required}"
NEW_TAG="${3:?new tag required}"
COMPOSE_PROJECT="dtc-${TENANT_NAME}-${ENV}"
DEPLOY_DIR="/srv/dtc/${TENANT_NAME}-${ENV}"
echo "=== Update: ${TENANT_NAME} (${ENV}) → tag ${NEW_TAG} ==="
cd "$DEPLOY_DIR"
# 1. Record current tag for rollback
CURRENT_TAG=$(git rev-parse --short HEAD)
echo "Current tag: ${CURRENT_TAG}"
# 2. Pull new tag
git fetch --tags --depth 1 origin "$NEW_TAG"
git checkout "$NEW_TAG"
# 3. Rebuild image (shared image, new tag)
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml build
# 4. Predeploy (auto-migrate)
echo "Running predeploy (auto-migrate)..."
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml \
run --rm medusa-server npm run predeploy
# 5. Restart services
echo "Restarting services..."
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml up -d
# 6. Health re-check
echo "Waiting for health..."
for i in $(seq 1 30); do
if docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml \
exec -T medusa-server wget -q -O /dev/null http://127.0.0.1:9000/health; then
echo "Medusa healthy."
break
fi
sleep 2
done
# 7. EVE health check
for i in $(seq 1 30); do
if docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml \
exec -T eve-agent wget -q -O /dev/null http://127.0.0.1:3000/eve/v1/health; then
echo "EVE agent healthy."
break
fi
sleep 2
done
echo "=== Update complete: ${TENANT_NAME} (${ENV}) → ${NEW_TAG} ==="
echo " Rollback: git checkout ${CURRENT_TAG} && docker compose up -d"
Rollback script (scripts/rollback.sh):
#!/usr/bin/env bash
# Prior-tag rollback.
# Usage: ./scripts/rollback.sh <tenant-name> <env>
set -euo pipefail
TENANT_NAME="${1:?tenant name required}"
ENV="${2:?environment required}"
COMPOSE_PROJECT="dtc-${TENANT_NAME}-${ENV}"
DEPLOY_DIR="/srv/dtc/${TENANT_NAME}-${ENV}"
echo "=== Rollback: ${TENANT_NAME} (${ENV}) ==="
cd "$DEPLOY_DIR"
# 1. Checkout prior tag
PRIOR_TAG=$(git rev-parse --short HEAD~1)
echo "Rolling back to: ${PRIOR_TAG}"
git checkout "$PRIOR_TAG"
# 2. Rebuild image
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml build
# 3. Restart (no predeploy — schema is already migrated forward;
# rollback is a code rollback, not a schema rollback)
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml up -d
# 4. Health re-check
echo "Waiting for health..."
for i in $(seq 1 30); do
if docker compose -p "$COMPOSE_PROJECT" -f docker-compose.prod.yml \
exec -T medusa-server wget -q -O /dev/null http://127.0.0.1:9000/health; then
echo "Medusa healthy."
break
fi
sleep 2
done
echo "=== Rollback complete: ${TENANT_NAME} (${ENV}) → ${PRIOR_TAG} ==="
Pipeline summary:
git tag v1.2.3 → push to template repo
│
▼
update.sh <tenant> <env> v1.2.3
│
├── git fetch + checkout v1.2.3
├── docker compose build (new image)
├── predeploy (auto-migrate)
├── docker compose up -d (restart)
├── health check: /health (Medusa)
└── health check: /eve/v1/health (EVE)
│
▼
tenant live on v1.2.3
rollback.sh <tenant> <env>
│
├── git checkout v1.2.2 (prior tag)
├── docker compose build
├── docker compose up -d
└── health check: /health
│
▼
tenant live on v1.2.2
Key design decisions:
- Checkout-before-cutover: the old image stays running until the new one is built and healthy. Zero-downtime deploys.
- Predeploy auto-migrate:
npm run predeployrunsmedusa db:migrateon every update. This is the Medusa-documented update hook. - Health-gated: the tenant is not marked healthy until both Medusa
/healthand EVE/eve/v1/healthreturn OK. - Rollback = prior tag: no schema rollback (migrations are forward-only). The prior tag runs against the current schema. If a migration is incompatible, the fix is a new migration + new tag, not a rollback.
4. Cross-links
Tam's verified studies (inputs, not re-derived)
| Study | What it establishes |
|---|---|
medusa-hosting-docker-study.md | 4-service compose stack, server/worker split, per-tenant isolation, Oracle ARM capacity (~4 stacks at 4.8 GB) |
medusa-nextjs-commerce-study.md | Medusa v2 MIT, dtc-starter, Agilo POS (MIT, Admin REST, no payments), licence matrix |
medusa-eve-integration-study.md | EVE agent architecture, useEveAgent(), HIL gates, key scoping, build sequencing |
hermes-eve-cohosting-study.md | EVE framework vs Hermes, co-hosting RAM table, self-host topology, version pinning |
Min's deep dive
| Document | What it contributes |
|---|---|
3-package-deep-dive.md | Product restatement, gaps (services vs goods, EVE beta risk, POS payments gap, multi-tenant memory, Oracle free tier), architecture decision table |
Mira's business plan
| Document | What it contributes |
|---|---|
business-plan-hosted-commerce-pos-ai-staff.md | Pricing tiers ($79/$149/+$49), unit economics, competitor bench, GTM sequencing, risk register |
Tam's unified package deep-dive
| Document | What it contributes |
|---|---|
unified-package-deepdive.md | Packaging mechanics (feature toggles, single deployment unit, tier ladder, update/rollback, data/backup), commercial inputs |
5. Open questions for Young
These are decisions that change the build. Each has a recommendation.
Q1: Services vs physical goods for the pilot
Question: Is the target customer selling physical goods, services, or both?
Recommendation: pilot with physical/simple goods. The services catalogue module (custom Medusa module for bookable services) is scoped after the first tenant ships. This matches Tam's study recommendation and Min's deep dive.
Why: a services business needs booking/scheduling, not a shopping cart. These are different products wearing the same brand. Pick one vertical for the pilot.
Q2: POS payments at launch or Phase 2?
Question: Does the pilot need working POS payments from day one?
Recommendation: Phase 2. Sell "website + AI staff" first — the AI staff layer is the differentiator. POS payments can follow when Agilo's terminal roadmap lands.
Why: Agilo POS has no integrated payments (manual path only). For a product sold as "website + POS + AI staff," the POS being unable to take payments is a gap in the core value prop. But the AI staff layer is the wedge — lead with it.
Q3: EVE beta risk acceptance
Question: EVE is beta (Vercel beta terms, API churn expected). What's the rollback story when EVE breaks?
Recommendation: accept with mitigations. Pin exact versions, per-tenant EVE health check in the update pipeline, adopt for tenants first (fleet later).
Why: pinning helps with reproducibility. The update pipeline includes a per-tenant EVE health check before the tenant is marked healthy. If EVE breaks between pinned versions, the tenant stays on the last known-good version.
Q4: Oracle ARM free tier as production foundation?
Question: What happens when the first paying tenant goes down because Oracle reclaimed the ARM capacity?
Recommendation: pilot on Oracle ARM (free, good for validation), but the production target should be a paid VPS or dedicated node. The per-tenant compose model makes migration trivial.
Why: Oracle ARM free tier is generous but it's a free tier — no SLA, no guarantee of availability. For a paid product hosted on a free tier, the COGS is near-zero but the risk is total.
Q5: Multi-tenant memory ownership
Question: Is per-tenant memory stored in the tenant's own stack (EVE file provider, tenant-scoped) or in a shared memory service?
Recommendation: start with EVE's file provider (tenant-scoped, zero infra). Revisit when a second tenant demands memory portability.
Why: the former is simpler but means memory doesn't survive tenant migration. The latter is more complex but enables cross-tenant learning (with consent).
6. Not established
Stated, not smoothed.
- No build exists. This is a design spec. Ace builds from it.
- EVE agent tools are specced, not tested. The 4 starter tools (products, carts, checkout, order-status) are designed against Medusa's Store API and EVE's tool conventions, but have not been executed against a live tenant.
- Agilo POS manual payment path is documented, not tested. The flow (draft order → manual payment → mark paid) is the Medusa-documented path but has not been exercised end-to-end.
- nginx config is specced, not deployed. The per-tenant domain + TLS routing is designed but not yet configured on the Oracle ARM node.
- Onboarding script is specced, not run. The script is designed against the template repo structure but has not been executed against a live template.
- Update/rollback pipeline is specced, not tested. The git-tag → pull → predeploy → restart → health flow is designed but not yet exercised with a real tenant.
- Services catalogue module is not scoped. The custom Medusa module for bookable services is a future build item, not part of the pilot.
7. Build sequencing (Ace lane, off this spec)
- Tenant template repo: fork dtc-starter + prod Dockerfile + docker-compose.prod.yml
- onboarding script + cutover script.
- Per-tenant secrets register:
.env.secrets.registergeneration in onboarding script, 0600, gitignored. - EVE agent template:
eve init+medusa.tsconnection + 4 starter tools + 2 skills + HIL workflow skeleton + version-pinned@workflow/*line. - One demo tenant: storefront + chat + one skill live, gated on
/health+/eve/v1/healthboth OK. - POS app: Agilo POS pointing at the demo tenant backend.
- Update pipeline: git-tagged template → pull → predeploy → restart → health re-check. Rollback = prior tag.
- Multi-env: staging + production compose projects for the demo tenant.
8. Design rules, and the defect each one closes
| Rule | Source | The defect it closes |
|---|---|---|
| Per-tenant compose project | Tam's study | shared cluster multi-tenancy is for scaling one tenant, not isolating many |
| Shared image, per-tenant env | Tam's study | rebuilding per-tenant would saturate 4 vCPUs |
0.0.0.0 binds forbidden | task brief | per-tenant isolation is the security model |
| Secrets 0600, never in compose | fleet hygiene | compose files are version-controlled; secrets are not |
| Health-gated cutover | Medusa docs | no stack is "live" until /health returns OK |
| Checkout-before-cutover | Tam's study | zero-downtime deploys |
| Predeploy auto-migrate | Medusa docs | npm run predeploy runs medusa db:migrate on every update |
| Version-pinned EVE | Tam's study | EVE beta API churn |
| Internal compose network | this spec | no container exposes a host port; only nginx reaches services |
env_file for secrets | this spec | Docker reads secrets directly; no shell interpolation |
9. Sources
- Tam's four verified studies (live-probed 2026-10-13):
medusa-hosting-docker-study.md,medusa-nextjs-commerce-study.md,medusa-eve-integration-study.md,hermes-eve-cohosting-study.md - Min's deep dive:
3-package-deep-dive.md - Mira's business plan:
business-plan-hosted-commerce-pos-ai-staff.md - Tam's unified package:
unified-package-deepdive.md - Medusa docs:
docs.medusajs.com/learn/installation/docker,learn/deployment,learn/production/worker-mode - EVE docs:
eve.dev/docs/guides/frontend/overview,docs/guides/deployment/self-hosting,docs/concepts/project-structure - GitHub:
medusajs/dtc-starter(MIT),Agilo/medusa-pos-starter(MIT),vercel/eve(Apache-2.0)

