Deployment
Deploy the UI, API worker, docs, scheduler, SQLite database, billing, and private connected-model runtime safely.
Work on Repeat is prepared as five workspace apps:
apps/ui— one Next.js service for marketing, the blog, authentication, onboarding, and the dashboard;apps/api— Hono API, run execution, and scheduler;apps/agent-runtime— private Node 22 runtime for connected model providers;apps/db— Drizzle schema, migrations, and repositories; andapps/docs— this Fumadocs Next.js service.
Three are deployed by Ploy and each carries its own ploy.yaml. The agent
runtime is a separately provisioned private OCI service. Ploy detects the
monorepo automatically, installs from the repository root so workspace
dependencies resolve, and runs each build from its base directory.
Projects
| App | kind | Entry | Notes |
|---|---|---|---|
apps/ui | nextjs | App Router | Marketing, blog, auth, and dashboard |
apps/docs | nextjs | App Router | This documentation site |
apps/api | worker | src/worker.ts | Runs on workerd, with a cron trigger |
apps/api/src/worker.ts is the workerd entrypoint. src/server.ts remains the
Node entrypoint used by pnpm dev and by any non-Ploy host. The difference is
how background work happens: the Node process runs a polling run-worker, while
on Ploy the cron trigger drives the same operations tick and a manually
dispatched run executes on the request's waitUntil.
Ploy builds the worker with its Node compatibility defaults and injects the
provisioned SQLite database as env.DB. The worker never opens a remote
database connection.
Environment contract
Variables are set in each project's Ploy settings — the manifests declare no
env block (Ploy removed the field, and a build that still declares one
fails). Everything set on a project is exported into its build environment
and reaches the worker as env.vars.*.
Set these on the API project:
API_ORIGIN
UI_ORIGIN
NODE_ENV=production
DEMO_MODE=false
BETTER_AUTH_SECRET
ENCRYPTION_KEY
SCHEDULER_SECRET
LLM_GATEWAY_API_KEY
LLM_GATEWAY_DEFAULT_MODEL
AUTH_EMAIL_CAPTURE_ENABLED=false
AUTH_EMAIL_FROM
AUTH_EMAIL_REPLY_TO
RESEND_API_KEY
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET
STRIPE_STARTER_MONTHLY_PRICE_ID
STRIPE_PRO_MONTHLY_PRICE_ID
STRIPE_SCALE_MONTHLY_PRICE_ID
COMMERCIAL_ACQUISITION_ENABLED=false
STRIPE_CHECKOUT_ENABLED=false
STRIPE_AUTOMATIC_TAX_ENABLED=false
POSTHOG_PROJECT_TOKEN
POSTHOG_HOSTSet API_ORIGIN, DOCS_ORIGIN, and
COMMERCIAL_ACQUISITION_ENABLED=false on the UI project. The acquisition
switch must have the same value on API and UI before either deployment is
promoted. Leave SECURITY_CONTACT_ENABLED unset until the apex MX records
land; setting it to true starts serving /.well-known/security.txt, and
advertising a contact whose mailbox bounces is worse than a 404 (see
docs/operations/inbound-email.md).
Email verification and password reset share AUTH_EMAIL_FROM,
AUTH_EMAIL_REPLY_TO, and the secret RESEND_API_KEY. Production must set
AUTH_EMAIL_CAPTURE_ENABLED=false and use a verified Resend sender plus a
monitored Reply-To mailbox. Password reset requires no new environment
variable. MANAGED_MCP_RESEND_ENABLED remains unrelated; it controls whether
customers can create Resend MCP connections, not authentication email.
ENCRYPTION_KEY must be a base64-encoded 32-byte value, and
BETTER_AUTH_SECRET and SCHEDULER_SECRET must each be at least 32
characters. Secrets should differ in every environment. The API refuses to start
in production when fixture mode is enabled, LLM_GATEWAY_API_KEY is absent, or
Stripe billing is only partially configured.
Every variable, including the optional OAuth providers, is documented in Environment variables.
Database
apps/api/ploy.yaml declares a DB binding. Ploy provisions the SQLite
database and applies every ordered migration under apps/api/migrations/DB
before the worker goes live. Keep the Drizzle journal and snapshots with each
migration; never edit a migration that has reached production.
The Node development entrypoint uses the file selected by SQLITE_URL. Run
pnpm db:migrate before pnpm db:seed locally. Do not set SQLITE_URL on the
Ploy worker.
Stripe billing
Create separate sandbox and live Products and Prices. Configure three active
USD recurring monthly Prices: Starter at $10.00, Pro at $39.00, and Scale
at $99.00. Never reuse a sandbox price_… identifier in live mode. Set the
Stripe secret, webhook secret, and all three Price IDs on the API project, then
register:
POST https://api.workonrepeat.com/webhooks/stripeSubscribe only to the events the projector handles:
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.paid
invoice.payment_failed
invoice.payment_action_requiredUse the endpoint's own live signing secret as STRIPE_WEBHOOK_SECRET. Checkout
always enables promotion codes and saves updated billing addresses.
STRIPE_AUTOMATIC_TAX_ENABLED independently enables Stripe Automatic Tax only
after the business has verified its registrations, Product tax code, and tax
behavior. The Stripe customer portal must be configured to update payment
details, expose invoices and tax IDs, collect cancellation reasons, cancel at
period end, and switch among Starter, Pro, and Scale.
All three launch switches default to false.
COMMERCIAL_ACQUISITION_ENABLED opens the public offer on API and UI;
STRIPE_CHECKOUT_ENABLED independently permits new Checkout Sessions; and
STRIPE_AUTOMATIC_TAX_ENABLED controls tax calculation on those Sessions.
Keep each switch off until its own checklist is approved.
Never set BILLING_DEMO_MODE on a deployed environment. It is a Node-local
verification adapter, is not declared in ploy.yaml, and production startup
rejects application demo mode.
The complete sandbox, cutover, secret-rotation, rollback, and alert checklist is
in docs/operations/stripe-launch.md in the repository.
UI security headers
The UI applies one production policy to marketing, authentication, and product
routes. PostHog scripts and ingestion remain same-origin through /rhythm;
Stripe Checkout, the customer portal, and OAuth providers use top-level
navigation rather than embedded frames. The CSP therefore needs no third-party
script, connection, font, image, or frame origins.
The CSP keeps narrowly documented inline script and style allowances because
Next.js emits inline App Router payloads for prerendered pages, the marketing
site emits inline JSON-LD, and a few components use inline animation styles. It
does not allow unsafe-eval in production, denies inline event-handler
attributes, and denies framing through both frame-ancestors 'none' and
X-Frame-Options: DENY. Cross-Origin-Opener-Policy and
Cross-Origin-Resource-Policy match the API's same-origin policy;
Cross-Origin-Embedder-Policy remains unset because the UI does not require
cross-origin isolation.
Production HSTS is max-age=63072000; includeSubDomains; preload. Keep the
apex redirect and every subdomain HTTPS before submitting workonrepeat.com to
the browser preload list. The Next.js deployment controls the www response;
the hosting layer remains responsible for the apex redirect response.
After promoting the UI, check representative marketing, authentication, and dashboard routes:
for path in / /sign-in /dashboard; do
curl -sSI "https://www.workonrepeat.com${path}" |
grep -Ei '^(content-security-policy|cross-origin-(opener|resource)-policy|permissions-policy|referrer-policy|strict-transport-security|x-content-type-options|x-frame-options):'
doneAn unauthenticated /dashboard request redirects but must still carry the
global policy. In an authenticated browser session, inspect the dashboard's
main document in the Network panel and confirm the same headers, then exercise
sign-in, one PostHog capture, an OAuth authorization redirect, Stripe Checkout,
and the customer portal without CSP errors in the console.
Scheduler
The apps/api project declares a one-minute cron trigger:
cron:
OPERATIONS_TICK: "* * * * *"Each tick claims due routines, executes queued runs, and retries webhook deliveries. Claims are atomic, so overlapping ticks never duplicate a run.
POST /internal/scheduler/tick with X-Scheduler-Secret remains available for
hosts that drive the schedule externally. On a deployment without a scheduler
configured it returns 503 scheduler_unavailable.
Without a tick, nothing runs
The scheduler is the only thing that claims due routines. If activated routines never execute, confirm the cron trigger is firing before looking anywhere else.
Before production traffic
DB migration.Complete the email-verification and password-reset smoke tests with the shared authentication-email configuration.
Complete a sandbox Checkout, signed webhook projection, portal update, period-end cancellation, failed payment, and recovery cycle.
Verify the live Starter, Pro, and Scale Prices are $10.00, $39.00, and
$99.00 monthly and all live Stripe variables belong to the same Stripe
account and mode.
nextRunAt.Confirm automatic execution for a test MCP tool and inspect its trace.
Connected model runtime
Connected ChatGPT, Copilot, and Anthropic models never execute inside workerd.
The API sends signed, encrypted envelopes over TLS to an API-only private
ingress on apps/agent-runtime. Version one is HTTP/1-only and exactly one
replica; do not enable horizontal scaling until session ownership and
idempotency have a separately reviewed design.
The deployment must enforce the x-agent-runtime contract in
ploy-workspace.yaml: non-root execution, read-only root filesystem, user
namespaces, a noexec,nosuid temporary volume, liveness and readiness probes,
and a parent-controlled egress proxy. Provider sessions run only through
RUNTIME_SANDBOX_COMMAND; direct DNS, direct internet access, ambient host
credentials, unlisted tools, and inherited CLI/IDE identity are denied. Set
capacity explicitly with RUNTIME_MAX_CONCURRENT_RUNS, alert before saturation,
and retain one-replica ownership during drains and rollback.
Keep these API and runtime flags independently dark until their own gate passes:
MODEL_PROVIDER_OPENAI_CODEX_ENABLED=false
MODEL_PROVIDER_GITHUB_COPILOT_ENABLED=false
MODEL_PROVIDER_ANTHROPIC_AGENT_ENABLED=falseEnabling one flag is not permission to enable another. The same value must be
delivered deliberately to the API and runtime; a missing or false flag rejects
new connection/catalog/run work for that provider. MODEL_PROVIDER_FAKE_ENABLED
is local/stub-only and is rejected in production.
The runtime currently pins Node 22 plus the reviewed official SDK/CLI pairing:
Codex @openai/codex-sdk@0.150.1 with @openai/codex@0.150.1, and Copilot
@github/copilot-sdk@1.0.11 with @github/copilot@1.0.79 protocol 3. Anthropic
pins @anthropic-ai/claude-agent-sdk@0.3.247, including its matching native
package, with @anthropic-ai/sdk@0.93.0. Treat a SDK, CLI, native package, Node,
wire, sandbox, or egress-host change as one compatibility upgrade. Build and
verify a fresh intended Linux x64 image and outer sandbox profile before
advancing a flag; source inspection is not image proof.
Secret rotation and observability
- The API and runtime each accept one
AGENT_RUNTIME_SHARED_SECRET, and version one has one runtime replica. For a rotation, disable new provider work on the API and runtime, drain bounded work, then update and restart the API and one runtime together in a coordinated maintenance cutover. Verify readiness and signed-envelope rejection for the old key, rerun the focused contract, fake/stub, error, and cleanup gates, and only then re-enable provider work. Never log either value or assume a previous/next key slot exists. - Rotate provider credentials through the authenticated owner flow. A new credential must validate before it replaces the prior encrypted version; revoke the old credential at the provider after success.
- Log only fixed categories, request/run identifiers, safe counters, duration, capacity, saturation, auth-error, duplicate-attempt, and cleanup outcomes. Redact Authorization, cookies, OAuth state/codes, API keys, encrypted envelopes, account details, prompts, provider bodies/errors, and tool payloads at ingress. A development test-log exclusion is not production logging proof.
- Triage by provider flag, readiness, current connection status/owner, fixed category, capacity, egress policy, and cleanup metadata. Do not paste raw provider errors or credentials into tickets.
Staged rollout gates
The rollout order is foundation flags off → fake runtime → Copilot internal → Codex internal → Anthropic API internal → opted-in projects → general availability. The ordering does not waive independent review: each provider advances only after its own adapter contract, stub browser journey, approved nonproduction catalog/text/inert-tool live check, redaction, capacity, saturation, auth-error, duplicate-attempt, and cleanup checks pass on the intended deployment profile.
pnpm test:e2e runs the consolidated local fake/stub browser gate in its
checked-in full scope. Its valid receipt is 11 passed with no failed or
skipped case, twelve fresh privacy observations, and ten completed signups. It
scans owned public artifacts and bounded in-memory output, then verifies owned
root and process cleanup. This is local fake-provider-only evidence; it is not
native, deployed, or live-provider evidence and cannot advance a provider flag.
A mocked 401, privileged diagnostic, local fake provider, component test, or source contract cannot replace the corresponding native/deployed/live gate. Each approved live proof must use the checked-in command, exact reviewed target tuple, foreground/CI restrictions, strict project selection, fixed catalog model, and read-only non-destructive inert tool. The checked-in target allowlists are empty by default. A live smoke SKIP is not a PASS. Do not add an override, use ambient credentials, contact a provider endpoint, or enable a flag until a target has been specifically approved in code review.
Current evidence and holds
- Dashboard6 — accepted through
b2916fd. The final same-run owned local fake-provider result was11 passed, with twelve fresh privacy observations, ten completed signups, scanner/root/process cleanup true, zero cleanup failures, and all four full-review findings closed. Browser automation was admitted to the actual initialized PostHog instance by a guarded test-only user-agent-filter override after observingwebdriver=trueand the default filter reject it. This is local fake-provider/privacy evidence, not deployed, native, live-provider, default bot-filter, or production telemetry proof.DASH-T6-OBS1—the ProjectSwitcher displayed default versus API actor default mismatch—remains a rollout/final-review observation. - Copilot5 — accepted at
2367206.pnpm smoke:copilotexists; the shipped target allowlist is empty and default invocation safely SKIPs before any request; independent rollout task review clean. Genuine OAuth, account catalog, text, exact inert-tool and session cleanup, a fresh Node 22 Linux x64 image/intended sandbox profile, and deployment remain unrun. The Copilot flag stays false. - Anthropic4 — accepted through
51c09b8.pnpm smoke:anthropicexists; the shipped target allowlist is empty and default invocation safely SKIPs before API-key access or a request; independent task review plus scoped Critical re-review clean. Genuine real-key catalog, text, tool and revoke, native Claude, Node 22/Linux image/intended sandbox, and deployment remain unrun. The Anthropic flag stays false. - OpenAI — rollout tooling and structural sandbox work remain accepted at
earlier recorded commits; external gates held.
pnpm smoke:openai-codexis default-off with an empty target allowlist. There is no approved interactive account smoke and no successful fresh assembled image after the external Debian signature failures. OpenAI and its diagnostic stay false.
These records are accepted inputs for rollout review, not a consolidated CI or production pass. The broad runtime/root suites, native provider binaries, Docker build/run, live smoke overrides, deployment, and flag enablement remain held until specifically authorized.
Per-provider rollback
Treat rollback as provider-specific; a healthy provider can remain unchanged. For the affected provider:
Set only its MODEL_PROVIDER_*_ENABLED flag false on the API and runtime to
disable new connections and run leases. Drain or terminate owned operations
within their bounds and verify non-content cleanup metadata.
Pause affected schedules with owner-specific recovery guidance. Preserve encrypted credentials and immutable history; rollback is not revocation or record deletion.
Restore the previously reviewed one-replica image, matching SDK/CLI/native pins, sandbox, and egress policy. Confirm private readiness before any new gate attempt.
Repeat the focused contracts, stub journey, capacity/error/cleanup checks, and the specifically approved live smoke before re-enabling that provider.
Per-provider rollback never reroutes a routine to LLMGateway, another connected provider, an API key, or a paid managed fallback. Operators must not revoke or delete credentials merely to roll back code; use the separate owner revocation flow only when compromise or owner intent requires it.
In short, per-provider rollback disables new connections and run leases, pauses affected schedules, and preserves encrypted credentials and immutable history while it never reroutes work to LLMGateway.