> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Deployment

> The backend ships to quill-api.useduro.com via GitHub Actions, CloudPanel, PM2, and nginx; the frontend runs on Vercel at quill.useduro.com; both point at Duro's sandbox for the demo.

Quill is deployed as two independent units, matching its two codebases: the NestJS API on a VPS behind nginx, and the Next.js frontend on Vercel. The two are joined only by the frontend's `BACKEND_API_URL`, and both talk to Duro in **sandbox** mode with `sk_test` keys.

## Topology

```mermaid theme={null}
flowchart LR
    subgraph vercel["Vercel"]
        FE["quill.useduro.com<br/>Next.js 16 (next start --port 7013)"]
    end
    subgraph vps["VPS (CloudPanel)"]
        NX["nginx :443"]
        PM2["PM2: quill-api<br/>dist/src/main.js, :4010"]
        PG[("Postgres")]
        RDS[("Redis")]
    end
    subgraph cf["Cloudflare"]
        R2[("R2 bucket<br/>images + HLS")]
    end
    subgraph duro["Duro"]
        SB["sandbox API (sk_test)"]
    end

    FE -->|"BFF proxy → BACKEND_API_URL"| NX --> PM2
    PM2 --> PG & RDS
    PM2 --> R2
    PM2 -->|"x-api-key sk_test"| SB
    SB -.->|"signed webhooks"| NX
```

## Backend: CI/CD to quill-api.useduro.com

The API deploys from the `quill-backend` branch through GitHub Actions (`.github/workflows/deploy-quill-api.yml`). The pipeline is CI-gated, then does a tarball release onto the VPS.

```mermaid theme={null}
flowchart TD
    PUSH["push to quill-backend branch"] --> CI["CI job:<br/>pnpm install --frozen-lockfile<br/>pnpm typecheck<br/>pnpm lint (non-blocking)<br/>pnpm build"]
    CI --> BUILD["Deploy job: pnpm build again"]
    BUILD --> PKG["package dist + node_modules + deploy/<br/>+ ecosystem.config.cjs → tar.gz"]
    PKG --> SCP["sshpass scp tarball to VPS<br/>~/htdocs/quill-api.useduro.com/incoming"]
    SCP --> REL["release.sh {releaseId}:<br/>unpack to releases/{id}, symlink, pm2 reload"]
    REL --> HEALTH["health check: :4010/api/v1/health"]
```

* **Runtime**: Node 24.3.0, pnpm 10.5.0, `--frozen-lockfile` (the lockfile is law). The CI job runs typecheck + build and blocks the deploy on failure; lint is non-blocking.
* **Release model**: a timestamped release id, versioned `releases/{id}` directories, and a `release.sh` that swaps the active symlink and reloads PM2. This gives atomic releases and easy rollback.
* **Process**: PM2 runs a single app named `quill-api` in `fork` mode (`instances: 1`), script `dist/src/main.js`, `max_memory_restart: 512M`, logs to a shared `logs/` dir (`ecosystem.config.cjs`). The app listens on port **4010** in production; nginx (managed by CloudPanel) terminates TLS and reverse-proxies `quill-api.useduro.com` to it.
* **Health**: the workflow polls `http://127.0.0.1:4010/api/v1/health` after release.

Because the CI packages `node_modules` in the tarball (including the platform-built `ffmpeg-static` used for HLS transcode), the VPS does not reinstall on release; it unpacks and reloads.

<Note>
  Database migrations are managed with `drizzle-kit` (`db:generate` / `db:migrate` / `db:push`) against `DATABASE_URL` (`drizzle.config.ts`). The schema source of truth is `src/database/schema/index.ts`.
</Note>

## Frontend: Vercel at quill.useduro.com

The Next.js app deploys to Vercel through its Git integration (there is no GitHub Actions workflow in `quill-frontend`; Vercel builds on push). It runs the App Router in Node runtime.

* **The one required env var is `BACKEND_API_URL`**, the origin the BFF proxy forwards to (defaults to `http://localhost:7010/api/v1` in dev; set to the production API in Vercel). The browser never sees this; it only ever calls the same-origin `/api/*` routes.
* **R2 upload** route (`app/api/upload/route.ts`) needs the R2 credentials in the Vercel environment, since image uploads are written to R2 directly from the edge/server function rather than proxied to Nest.
* The proxy and upload routes are `runtime = "nodejs"`; the catch-all proxy is `dynamic = "force-dynamic"` so responses are never cached.

## Configuration surface

Both sides are configured entirely through environment variables. The backend's config schema is `src/config/configuration.ts`; the Duro-facing keys are:

| Variable                                       | Purpose                                          | Demo value                              |
| ---------------------------------------------- | ------------------------------------------------ | --------------------------------------- |
| `DURO_MODE`                                    | `mock` or `live`                                 | `live` for the demo (points at sandbox) |
| `DURO_API_URL`                                 | Duro public API base                             | Duro sandbox `/v1`                      |
| `DURO_SECRET_KEY`                              | `x-api-key` for server calls                     | `sk_test_…`                             |
| `DURO_PUBLIC_KEY`                              | publishable key returned to the checkout client  | `pk_test_…`                             |
| `DURO_WEBHOOK_SECRET`                          | HMAC secret for `duro-signature` verification    | `whsec_…`                               |
| `DATABASE_URL`                                 | Postgres connection                              | VPS Postgres                            |
| `REDIS_HOST` / `REDIS_PORT` / `REDIS_PASSWORD` | BullMQ connection                                | VPS Redis                               |
| `R2_*`                                         | Cloudflare R2 (bucket, keys, public URL, prefix) | Cloudflare                              |
| `SMTP_*` / `EMAIL_FROM`                        | ZeptoMail SMTP (else emails are logged)          | ZeptoMail                               |
| `JWT_SECRET` / `COOKIE_NAME`                   | session auth (`quill_session`)                   | per-env                                 |

## Running against Duro's sandbox

The reference configuration is Quill in `live` mode with `sk_test` keys against **Duro's sandbox**. This exercises the entire real integration path (HTTP calls, idempotency, signed webhooks, transfers, VAS) with no real funds moved. `mock` mode remains available as a fully offline dev mode where checkout auto-completes and payouts return `paid` deterministically, driven where needed by the `POST /api/v1/webhooks/duro/simulate` endpoint.

The net effect is two products, deployed independently on their natural platforms (VPS + Vercel), meeting only at Duro's public API, which is exactly the shape a real merchant integration takes. Return to [Introduction](/quill/introduction) for the overview, or [Duro Integration](/quill/duro-integration) for the client internals.
