From 9f9108074f14a3fc4bc8986f08c36c73edfda061 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 4 May 2026 22:50:09 -0500 Subject: [PATCH] Fresh Unraid Install --- ops/unraid-install.md | 1339 +++++++++++++++++++++++++++++------------ 1 file changed, 939 insertions(+), 400 deletions(-) diff --git a/ops/unraid-install.md b/ops/unraid-install.md index 6202f93..4212c12 100644 --- a/ops/unraid-install.md +++ b/ops/unraid-install.md @@ -1,195 +1,573 @@ -# Unraid Installation Guide +# Storybid — Unraid Install Guide (CLI Only) -Storybid runs as four Docker containers (PostgreSQL, Redis, server, client) managed -by a single `docker-compose.yml`. This guide covers two installation paths: +End-to-end installation of Storybid on a freshly provisioned Unraid server, in +the order you should perform each step. Every operation in this document runs +from the Unraid terminal (SSH or **Tools → Web Terminal**). No Community +Applications GUI workflows are required for the application stack itself. -- **CLI** — SSH into Unraid and use the terminal directly. Recommended; always works. -- **GUI** — Use the Community Applications *Compose Manager* plugin to manage the - stack from the Unraid web interface. - -Both methods produce an identical running system. +The result is a four-container Compose stack (Postgres + Redis + API server + +Nginx client) reachable on your LAN, fronted by Nginx Proxy Manager on the +public internet, with Stripe payments, Twilio Verify SMS OTP, transactional +email, and event-night LAN failover all wired up. --- -## Prerequisites +## Contents -| Requirement | Notes | +1. [Bill of materials](#1-bill-of-materials) +2. [External account prerequisites](#2-external-account-prerequisites) +3. [DNS and domain prep](#3-dns-and-domain-prep) +4. [Stripe account setup (web)](#4-stripe-account-setup-web) +5. [Twilio Verify setup (web)](#5-twilio-verify-setup-web) +6. [SMTP relay setup (web)](#6-smtp-relay-setup-web) +7. [Unraid server first boot](#7-unraid-server-first-boot) +8. [Unraid system configuration](#8-unraid-system-configuration) +9. [Required plugins (one-time, via terminal)](#9-required-plugins-one-time-via-terminal) +10. [Reserve the server's LAN IP](#10-reserve-the-servers-lan-ip) +11. [Open SSH and connect from your workstation](#11-open-ssh-and-connect-from-your-workstation) +12. [Lay out the appdata directory](#12-lay-out-the-appdata-directory) +13. [Get the source code on the server](#13-get-the-source-code-on-the-server) +14. [Configure `.env`](#14-configure-env) +15. [Pin Docker volumes to host paths](#15-pin-docker-volumes-to-host-paths) +16. [Build and start the stack](#16-build-and-start-the-stack) +17. [Initialize the database schema](#17-initialize-the-database-schema) +18. [Create the first organization and admin user](#18-create-the-first-organization-and-admin-user) +19. [Install Nginx Proxy Manager and issue a certificate](#19-install-nginx-proxy-manager-and-issue-a-certificate) +20. [Register the Stripe webhook](#20-register-the-stripe-webhook) +21. [UniFi event-night network configuration](#21-unifi-event-night-network-configuration) +22. [End-to-end smoke test](#22-end-to-end-smoke-test) +23. [Backups and disaster recovery](#23-backups-and-disaster-recovery) +24. [Updates and rollback](#24-updates-and-rollback) +25. [Troubleshooting](#25-troubleshooting) + +--- + +## 1. Bill of materials + +| Item | Recommended | |---|---| -| Unraid 6.12 or later | Earlier versions lack the built-in Compose support CLI needs | -| Community Applications plugin | Required for GUI method; install from CA store | -| Docker enabled | Unraid → Settings → Docker → Enable Docker = Yes | -| At least 2 GB free RAM | PostgreSQL + Redis + Node API + Nginx | -| A user share for appdata | Default: `/mnt/user/appdata/` | -| A public domain name | For Stripe webhooks and the public bidder URL | -| Stripe account | Live or test; publishable + secret key + webhook secret | -| Twilio Verify service | For SMS OTP login (optional but recommended) | -| SMTP relay | Any transactional email provider (Gmail, Postmark, Mailgun…) | +| Server hardware | x86_64 box with 4+ cores, 8 GB RAM, 256 GB SSD for appdata | +| OS | Unraid 6.12 or later, written to a 16 GB+ USB stick | +| Network | Gigabit LAN, UniFi gateway + AP (any model with Local DNS support) | +| UPS | Any battery backup with USB; 10+ minute runtime under load | +| Public domain | One A-record under your control (e.g. `bid.example.org`) | +| Workstation | A laptop with `ssh` and a browser to drive setup | --- -## Directory Layout +## 2. External account prerequisites -All persistent data lives under a single appdata directory. The recommended path is: +You will need accounts at: -``` -/mnt/user/appdata/storybid/ -├── repo/ ← cloned source code + docker-compose.yml -│ ├── .env ← your live config (never commit this file) -│ └── docker-compose.yml -├── postgres/ ← PostgreSQL data volume (managed by Docker) -├── redis/ ← Redis data volume (managed by Docker) -└── uploads/ ← uploaded item media (mapped from media_data volume) -``` +- **Stripe** — payments. Sign up at . +- **Twilio** — SMS OTP. Sign up at . +- **SMTP provider** — Postmark, Mailgun, SendGrid, AWS SES, or your own relay. +- **Domain registrar** — Cloudflare, Namecheap, Route 53, etc. -> **Tip:** Keep `repo/` on a cache-backed share so builds are fast, but ensure the -> share has `Use Cache: Prefer` or `Only` so data doesn't move to array disks mid-event. +Sections 4 – 6 walk through these end to end. Do them **before** touching the +server so you can paste the credentials directly into `.env` later. --- -## Environment Variables Reference +## 3. DNS and domain prep -Copy `.env.example` to `.env` and fill in every value before starting the stack. +You need one public hostname for bidders (e.g. `bid.example.org`). Stripe +webhooks and PWA installation both require a publicly resolvable HTTPS URL. + +1. Log in to your DNS provider. +2. Create an **A record**: + - Name: `bid` (or any subdomain you prefer) + - Type: `A` + - Value: your home/event router's public WAN IP + - TTL: 300 (low, so failover edits propagate quickly) +3. If your WAN IP is dynamic, also enable Dynamic DNS or use a provider like + Cloudflare with the API-based updater on Unraid. +4. At your home router, forward TCP **80** and **443** to the Unraid server's + LAN IP (you'll reserve that IP in section 10). + +Verify from your workstation: ```bash -cp /mnt/user/appdata/storybid/repo/.env.example \ - /mnt/user/appdata/storybid/repo/.env +dig +short bid.example.org # should return your WAN IP ``` -### Core app - -| Variable | Example | Description | -|---|---|---| -| `NODE_ENV` | `production` | Must be `production` for live deployments | -| `PORT` | `3001` | Internal port the API server listens on | -| `PUBLIC_URL` | `https://bid.example.org` | Externally reachable HTTPS URL. Used in magic-link emails and as the first-choice socket endpoint | -| `LOCAL_HOSTNAME` | `auction.event.lan` | LAN hostname for event-night failover (see [unifi-dns.md](./unifi-dns.md)). Must resolve on the event Wi-Fi. | -| `JWT_SECRET` | `64-char random string` | Signs all auth tokens. Generate with `openssl rand -hex 32` | -| `CLIENT_URL` | `https://bid.example.org` | Allowed CORS origin for the client; usually same as `PUBLIC_URL` | - -### Database - -| Variable | Example | Description | -|---|---|---| -| `DATABASE_URL` | `postgresql://storybid:PASS@db:5432/storybid` | Postgres connection string. The hostname is `db` (the Docker service name) in production | - -> **Change the password** in both `DATABASE_URL` and the `docker-compose.yml` -> `POSTGRES_PASSWORD` entry before first boot. - -### Redis - -| Variable | Example | Description | -|---|---|---| -| `REDIS_URL` | `redis://redis:6379` | Redis connection string. The hostname is `redis` inside the compose network | - -### Stripe - -| Variable | Example | Description | -|---|---|---| -| `STRIPE_SECRET_KEY` | `sk_live_…` | Stripe secret key from Dashboard → Developers → API keys | -| `STRIPE_PUBLISHABLE_KEY` | `pk_live_…` | Stripe publishable key — returned to bidder browsers to initialise payment forms | -| `STRIPE_WEBHOOK_SECRET` | `whsec_…` | Signing secret for `POST /api/webhooks/stripe`. Created when you register the webhook endpoint in Stripe Dashboard | - -### Twilio Verify (SMS OTP) - -| Variable | Example | Description | -|---|---|---| -| `TWILIO_ACCOUNT_SID` | `ACxxxx…` | Found on your Twilio Console dashboard | -| `TWILIO_AUTH_TOKEN` | `xxxx…` | Found on your Twilio Console dashboard | -| `TWILIO_VERIFY_SERVICE_SID` | `VAxxxx…` | Create a Verify service in Twilio Console → Verify → Services | - -Leave all three blank to disable SMS login (email magic links still work). - -### Email - -| Variable | Example | Description | -|---|---|---| -| `SMTP_HOST` | `smtp.postmarkapp.com` | Outbound SMTP server | -| `SMTP_PORT` | `587` | SMTP port (587 = STARTTLS, 465 = SSL) | -| `SMTP_USER` | `noreply@example.org` | SMTP username / sender address | -| `SMTP_PASS` | `…` | SMTP password or API key | -| `EMAIL_FROM` | `Storybid ` | Display name + address in the From header | - -### Media storage - -| Variable | Default | Description | -|---|---|---| -| `UPLOAD_DIR` | `/app/uploads` | Absolute path inside the server container where media files are written. Maps to `media_data` Docker volume | -| `MEDIA_BASE_URL` | `/media` | URL prefix the server uses to serve media. Change to an absolute URL if you front uploads with a CDN | - --- -## Method 1 — CLI via SSH +## 4. Stripe account setup (web) -### 1. Enable SSH on Unraid +The goal of this section is to walk away with three secrets: a publishable +key, a secret key, and a webhook signing secret. -**Unraid web UI → Settings → Management Access → SSH → Enable SSH = Yes** +### 4.1. Create the account -Connect from your workstation: +1. Go to . +2. Provide email, full name, and a password. Confirm via the email link. +3. Stripe starts you in **Test mode** — keep it there until you finish smoke + testing. Toggle **View test data** in the top-right at any time. -```bash -ssh root@192.168.1.X # replace with your Unraid IP +### 4.2. Activate your account (only required for live payments) + +1. In the dashboard, click **Activate account** in the left rail. +2. Provide: + - Business type (most charities: `Non-profit organization`) + - EIN / tax ID + - Business address + - Bank account for payouts (routing + account number) + - A representative's date of birth and SSN (last 4 in the US, full SSN + for higher payout volumes) +3. Submit. Activation usually clears within an hour for clean filings. + +You can keep test mode active for now and only flip the dashboard toggle to +**live** after the smoke test in section 22 passes. + +### 4.3. Grab the API keys + +1. **Developers → API keys**. +2. **Publishable key** — visible by default. Copy the value that starts with + `pk_test_…` (test mode) or `pk_live_…` (live mode). +3. **Secret key** — click **Reveal test key** (or **Create restricted key** + for live). Copy the value starting with `sk_test_…` / `sk_live_…`. +4. Stash both somewhere safe (password manager). You'll paste them into + `.env` in section 14. + +> **Warning:** the secret key is shown **once**. If you lose it, click +> **Roll key** to generate a new one — the old one is then immediately void. + +### 4.4. Note the webhook URL — you will register it after deployment + +The webhook **endpoint** can only be created once your public URL serves real +TLS, so we register the webhook in section 20 (after Nginx Proxy Manager is +up). For now just note the URL pattern: + +``` +https://bid.example.org/api/webhooks/stripe ``` -### 2. Clone the repository +You will receive the `whsec_…` signing secret at that point. + +--- + +## 5. Twilio Verify setup (web) + +Goal: walk away with three secrets — Account SID, Auth Token, and a Verify +Service SID. + +### 5.1. Create the account + +1. Go to . +2. Provide email, password, and a phone number for verification. Twilio + sends an SMS OTP to confirm the number. +3. After login, Twilio asks a few onboarding questions. Answer: + - **What do you want to do first?** → *Verify users* + - **Which Twilio product?** → *Verify* + - **What language?** → *Node.js* (label only; doesn't matter) +4. Land on the Twilio Console at . + +### 5.2. Get the Account SID and Auth Token + +1. On the Console home, look at the **Account Info** card. +2. Copy: + - **Account SID** — starts with `AC…` (34 chars) + - **Auth Token** — click **Show** to reveal; starts with hex chars +3. Stash both. They go in `.env` as `TWILIO_ACCOUNT_SID` and + `TWILIO_AUTH_TOKEN`. + +### 5.3. Create a Verify service + +1. In the left rail, expand **Explore Products → Verify**. +2. Click **Services → Create new** (or the **+** button). +3. Settings: + - **Friendly name**: `Storybid OTP` + - **Code length**: `6` + - **Default channel**: `SMS` + - Leave the rest at defaults; turn off Email OTP unless you've configured + a SendGrid integration. +4. Click **Create**. +5. On the resulting service page, copy the **Service SID** — starts with + `VA…`. This is `TWILIO_VERIFY_SERVICE_SID` in `.env`. + +### 5.4. Enable production countries (only for live use) + +Free trial accounts can only send to verified phone numbers. To send to any +US phone: + +1. Add a payment method: **Account → Billing → Payment method**. Recharge + $20 to start. +2. **Verify → Service → Geography** — confirm the countries you'll send to + are enabled (US is on by default). +3. **Phone Numbers → Manage → Buy a number** is **not** required for Verify + — Twilio uses a shared sender pool by default. + +### 5.5. Optional — leave SMS off + +If you don't want SMS at all, leave all three Twilio variables blank in +`.env`. Email magic-link login will still work for both bidders and staff. + +--- + +## 6. SMTP relay setup (web) + +You need any provider that gives you SMTP host + port + username + password. +Postmark is the simplest for transactional mail. Setup with any provider +follows the same pattern; Postmark example below. + +1. Sign up at with the email you'll send *from*. +2. Postmark requires sender verification: + - **Sender Signatures → Add Signature** with your `noreply@example.org` + address, **or** + - **Domains → Add Domain** to authorize the whole domain via DKIM + (recommended; one-time DNS records). +3. **Servers → My First Server → API Tokens** — create a server token and + copy it. With Postmark this token is **both** the SMTP username and + password. +4. Note the connection details (Postmark example): + - `SMTP_HOST=smtp.postmarkapp.com` + - `SMTP_PORT=587` + - `SMTP_USER=` + - `SMTP_PASS=` + - `EMAIL_FROM=Storybid ` + +Send a test message from the provider's dashboard before continuing — broken +DKIM or unverified senders will silently bounce magic-link emails. + +--- + +## 7. Unraid server first boot + +1. Download Unraid USB Creator: . +2. Plug in a 16 GB+ USB 2.0 stick (USB 2.0 is more compatible than 3.0 for + booting), pick the latest stable Unraid release, write it to the stick. +3. Plug the USB into the server, set BIOS to **boot from USB** first. +4. Power on. Pick **Unraid OS** at the GRUB prompt; the first boot takes + 2–3 minutes. +5. The console shows the LAN IP it received from DHCP, e.g.: + ``` + Welcome to Unraid Server OS! + Tower login: + IP address(es) of this server: 192.168.1.50 + ``` +6. From your workstation browser, go to `http://192.168.1.50` (replace with + your IP). The Unraid web UI loads. +7. Set the root password when prompted. **Use a strong one** — this is the + only protection on the box. +8. Register a free **Trial** key (or paste a purchased key) — **Tools → + Registration**. The trial is 30 days, plenty for the install + first event. + +--- + +## 8. Unraid system configuration + +Do all of these **before** building the array, since some require a stopped +array. + +### 8.1. Build the array + +1. **Main → Array Devices**: + - Assign one or more disks to the **Parity** slot (recommended for + production). + - Assign disk(s) to **Disk 1**, **Disk 2**, … as data drives. + - If you have an SSD, assign it to the **Cache** pool — Storybid's + `appdata` should live on cache for performance. +2. Click **Start** to format and bring the array online. First parity sync + can take hours; it does not block the rest of the install. + +### 8.2. Time, timezone, and identification + +- **Settings → Date and Time** — set timezone to where the events run; check + **Use NTP** with a public pool. +- **Settings → Identification** — name the server (`storybid-prod` is + conventional) and set a static workgroup if you care about SMB. + +### 8.3. Network + +- **Settings → Network Settings → eth0** — switch from DHCP to a static IP + matching the reservation you'll create on the router in section 10. Use + the same value, e.g. `192.168.1.50`, mask `255.255.255.0`, gateway your + router's LAN IP, DNS `192.168.1.1` (router) and `1.1.1.1` (fallback). +- Click **Apply**. The web UI may briefly disconnect — reconnect at the new + static IP. + +### 8.4. Docker + +- **Settings → Docker → Enable Docker** = `Yes`. +- Leave **Docker data-root** at the default (`/var/lib/docker` on the cache + pool). The Storybid stack uses ~3 GB built. +- Click **Apply**. + +### 8.5. SSH + +- **Settings → Management Access → Use SSH** = `Yes`. +- **Use SFTP** = `Yes` (handy for transferring files later). +- Click **Apply**. + +### 8.6. Notifications (optional but recommended) + +- **Settings → Notification Settings** — enable email or push (Discord, + Pushover, etc.) for **Warning**, **Alert**, **System**. You want to know + immediately if the array degrades during an event. + +--- + +## 9. Required plugins (one-time, via terminal) + +Open the Unraid web terminal: **top-right → Terminal icon (>_)**. The same +session is reachable from your workstation once SSH is enabled in the next +step — for now use the web terminal. + +Install Community Applications, which is the bootstrap plugin for everything +else: + +```bash +plugin install https://raw.githubusercontent.com/Squidly271/community.applications/master/plugins/community.applications.plgs +``` + +Then install **NerdTools** (gives you `git`, `nano`, `htop`, `unzip`, …): + +```bash +plugin install https://raw.githubusercontent.com/dmacias72/unRAID-NerdTools/master/plugins/nerdtools.plg +``` + +Activate `git` (NerdTools is a package selector — use its CLI): + +```bash +nerdctl install git nano unzip openssl +``` + +> If `nerdctl` is unavailable on your Unraid version, open **Apps → +> Installed Apps → NerdTools → Settings**, tick **git, nano, unzip, +> openssl**, click **Apply**. Plugin selection is the only GUI step +> in this guide — there is no terminal-equivalent because plugin +> selection writes to a config the plugin daemon watches. + +Verify: + +```bash +git --version +docker --version +docker compose version +openssl version +``` + +All four must print a version string. + +--- + +## 10. Reserve the server's LAN IP + +On your UniFi controller (or whatever router you use): + +1. **Client Devices → Tower (the Unraid box) → Settings → Network**. +2. **Fixed IP Address** = the IP you set in section 8.3 (e.g. + `192.168.1.50`). +3. Click **Save**. Reboot the Unraid box once to confirm it picks up the + reserved address (`reboot` from the terminal). + +--- + +## 11. Open SSH and connect from your workstation + +From your workstation: + +```bash +ssh root@192.168.1.50 # use your Unraid LAN IP +``` + +You'll be prompted for the root password you set in section 7. **Stay in +this SSH session for every remaining step.** + +(Optional) Copy your public key to skip the password prompt next time: + +```bash +ssh-copy-id root@192.168.1.50 +``` + +--- + +## 12. Lay out the appdata directory + +```bash +mkdir -p /mnt/user/appdata/storybid/{repo,postgres,redis,uploads,backups} +ls -la /mnt/user/appdata/storybid +``` + +You should see five subdirectories. They're owned by `nobody:users` (Unraid +default) — do not chown them. + +--- + +## 13. Get the source code on the server + +### 13.1. From a Git remote (preferred) ```bash -mkdir -p /mnt/user/appdata/storybid cd /mnt/user/appdata/storybid git clone https://github.com/YOUR_ORG/storybid.git repo cd repo +git status # confirm "nothing to commit, working tree clean" ``` -If you don't have `git` on Unraid, install it via the **NerdTools** Community -Applications plugin, or transfer a zip via SCP: +### 13.2. From a local zip (if your repo is private and unreachable) + +On your workstation, build a zip of the repository, then from Unraid: ```bash -# From your workstation -scp storybid.zip root@192.168.1.X:/mnt/user/appdata/storybid/ -# On Unraid -cd /mnt/user/appdata/storybid && unzip storybid.zip && mv storybid repo +# From your workstation: +scp storybid.zip root@192.168.1.50:/mnt/user/appdata/storybid/ + +# Back on Unraid: +cd /mnt/user/appdata/storybid +unzip storybid.zip -d repo +ls repo/ # should contain package.json, docker-compose.yml, etc. ``` -### 3. Configure the environment file +--- + +## 14. Configure `.env` ```bash cd /mnt/user/appdata/storybid/repo cp .env.example .env -nano .env # or vi .env ``` -Minimum required edits: +Generate a JWT secret and capture it: ```bash +openssl rand -hex 32 +``` + +Copy the 64-character output. Open `.env` for editing: + +```bash +nano .env +``` + +Fill in every line. Reference values: + +```bash +# ── Database ────────────────────────────────────────────────────────────────── +# Hostname is the docker-compose service name 'db'. +# Replace CHANGE_ME with a strong password — use the same value when you edit +# docker-compose.yml in section 15. +DATABASE_URL="postgresql://storybid:CHANGE_ME@db:5432/storybid" + +# ── Redis ───────────────────────────────────────────────────────────────────── +REDIS_URL="redis://redis:6379" + +# ── App ─────────────────────────────────────────────────────────────────────── NODE_ENV=production -PUBLIC_URL=https://bid.example.org -JWT_SECRET=$(openssl rand -hex 32) # paste output, don't run inline in .env +PORT=3001 +PUBLIC_URL="https://bid.example.org" +LOCAL_HOSTNAME="auction.event.lan" +JWT_SECRET="" -DATABASE_URL=postgresql://storybid:CHANGE_ME@db:5432/storybid -STRIPE_SECRET_KEY=sk_live_... -STRIPE_PUBLISHABLE_KEY=pk_live_... -STRIPE_WEBHOOK_SECRET=whsec_... -SMTP_HOST=smtp.example.com -SMTP_USER=noreply@example.org -SMTP_PASS=... -EMAIL_FROM=Storybid +# ── Stripe (from section 4) ─────────────────────────────────────────────────── +STRIPE_SECRET_KEY="sk_test_..." +STRIPE_PUBLISHABLE_KEY="pk_test_..." +# Leave blank for now — fill in after section 20: +STRIPE_WEBHOOK_SECRET="" + +# ── Twilio Verify (from section 5; leave blank to disable SMS) ──────────────── +TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +TWILIO_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +TWILIO_VERIFY_SERVICE_SID="VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# ── Media storage ───────────────────────────────────────────────────────────── +UPLOAD_DIR=/app/uploads +MEDIA_BASE_URL=/media + +# ── Email (from section 6) ──────────────────────────────────────────────────── +SMTP_HOST="smtp.postmarkapp.com" +SMTP_PORT=587 +SMTP_USER="" +SMTP_PASS="" +EMAIL_FROM="Storybid " ``` -Also update `docker-compose.yml` to match the Postgres password you set above: +Save and exit (Ctrl+O, Enter, Ctrl+X in nano). + +Strip any Windows line endings if the file was edited on Windows: ```bash -# In docker-compose.yml, under the db service: -POSTGRES_PASSWORD: CHANGE_ME -# And in the server service environment: -DATABASE_URL: postgresql://storybid:CHANGE_ME@db:5432/storybid +sed -i 's/\r$//' .env ``` -### 4. Update volume paths (optional — recommended) +--- -By default Docker manages named volumes internally. To make them visible in the -Unraid GUI and ensure they survive array changes, pin them to explicit paths: +## 15. Pin Docker volumes to host paths + +The shipped `docker-compose.yml` uses anonymous volumes by default. Replace +the `volumes:` block at the bottom so Postgres, Redis, and uploads land in +the appdata directories you created in section 12. Also align the Postgres +password with your `.env`. + +```bash +nano docker-compose.yml +``` + +Change the `db` service password and append/replace the bottom volumes block: ```yaml -# In docker-compose.yml, replace the volumes block at the bottom: +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: storybid + POSTGRES_PASSWORD: CHANGE_ME # ← match the password in .env + POSTGRES_DB: storybid + volumes: + - postgres_data:/var/lib/postgresql/data + # Remove the "ports: 5432" block for production — DB stays on Docker net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U storybid"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + # Remove the "ports: 6379" block for production + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + server: + build: + context: . + dockerfile: packages/server/Dockerfile + restart: unless-stopped + env_file: .env + environment: + DATABASE_URL: postgresql://storybid:CHANGE_ME@db:5432/storybid # ← match + REDIS_URL: redis://redis:6379 + NODE_ENV: production + UPLOAD_DIR: /app/uploads + MEDIA_BASE_URL: /media + volumes: + - media_data:/app/uploads + ports: + - "3001:3001" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + client: + build: + context: . + dockerfile: packages/client/Dockerfile + restart: unless-stopped + ports: + - "8080:80" + depends_on: + - server + volumes: postgres_data: driver: local @@ -213,346 +591,507 @@ volumes: device: /mnt/user/appdata/storybid/uploads ``` -Create the directories first: +Save and exit. -```bash -mkdir -p /mnt/user/appdata/storybid/{postgres,redis,uploads} -``` +--- -### 5. Build and start the stack +## 16. Build and start the stack ```bash cd /mnt/user/appdata/storybid/repo docker compose up -d --build ``` -First build downloads base images and compiles the TypeScript source — allow 3–5 -minutes. Watch progress: +First build pulls base images and compiles TypeScript — allow 3–8 minutes +depending on internet speed. Watch progress in another terminal pane: ```bash docker compose logs -f ``` -All four containers should reach **Up** state: - -``` -NAME STATUS -storybid-db-1 Up (healthy) -storybid-redis-1 Up (healthy) -storybid-server-1 Up -storybid-client-1 Up -``` - -### 6. Run database migrations - -Run once after the first boot, and again after any update that includes schema changes: +When the build finishes, confirm all four services are running: ```bash -docker compose exec server npx prisma migrate deploy +docker compose ps ``` -### 7. Create the first admin user +You want all four lines reading `Up` (and `db`/`redis` reading `healthy`): + +``` +NAME STATUS +storybid-db-1 Up (healthy) +storybid-redis-1 Up (healthy) +storybid-server-1 Up +storybid-client-1 Up +``` + +--- + +## 17. Initialize the database schema + +This repository does not ship Prisma migration files — production deploys +the schema with `prisma db push`, which syncs the database to +`schema.prisma` directly. ```bash -docker compose exec server node -e " -const { prisma } = await import('./dist/lib/prisma.js'); -await prisma.organization.create({ +docker compose exec server npx prisma db push --skip-generate +``` + +Expected output: + +``` +🚀 Your database is now in sync with your Prisma schema. +``` + +Verify the tables exist: + +```bash +docker compose exec db psql -U storybid -d storybid -c '\dt' +``` + +You should see ~15 tables (`Organization`, `AuctionEvent`, `Auction`, +`AuctionItem`, `Bidder`, `Bid`, `Invoice`, etc.). + +--- + +## 18. Create the first organization and admin user + +The server image bundles the seed script. Run it once to create a default +organization and demo event: + +```bash +docker compose exec server npx tsx prisma/seed.ts +``` + +Then create your real organization and admin staff user. Replace the values +inline: + +```bash +docker compose exec server node --input-type=module -e " +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); + +const org = await prisma.organization.create({ data: { - name: 'My Charity', + name: 'Your Charity Name', + slug: 'your-charity', + primaryColor: '#2563eb', + publicUrl: 'https://bid.example.org', + localHostname: 'auction.event.lan', staffUsers: { create: { - name: 'Admin', + name: 'Site Admin', email: 'admin@example.org', role: 'admin', - passwordHash: null, - } - } - } + }, + }, + }, + include: { staffUsers: true }, }); -console.log('Done'); + +console.log('Organization:', org.id, org.name); +console.log('Admin:', org.staffUsers[0].email); await prisma.\$disconnect(); " ``` -Then use the magic-link login flow at `http://UNRAID-IP:8080` to sign in with -`admin@example.org`. The magic link will arrive at the SMTP address you configured. +The command prints the generated IDs. Sign in flow: + +1. Open `http://192.168.1.50:8080` in a browser on the same LAN. +2. Click **Sign in**, enter `admin@example.org`. +3. The magic-link email lands in your SMTP inbox (check spam). +4. Click the link — you're now signed in as admin. + +If the email doesn't arrive, check `docker compose logs server | grep -i mail` +for SMTP errors. --- -## Method 2 — GUI via Compose Manager Plugin +## 19. Install Nginx Proxy Manager and issue a certificate -The **Docker Compose Manager** plugin lets you manage compose stacks entirely from -the Unraid web interface without touching the terminal. +The application stack runs on plain HTTP internally. NPM terminates TLS in +front of it, handles Let's Encrypt renewals, and proxies WebSockets. -### 1. Install the plugin - -1. Open **Unraid web UI → Apps** (Community Applications). -2. Search for **"Compose Manager"**. -3. Click **Install** on the result by *dcflachs*. -4. After installation, a new **Compose** tab appears in the Docker section. - -### 2. Upload the source files - -The plugin reads compose files from `/boot/config/plugins/compose.manager/projects/`. -Each project is a subdirectory containing `docker-compose.yml` and optionally a `.env`. - -**Option A — File Manager plugin (easiest)** - -1. Install **Unraid File Manager** from Community Applications. -2. Navigate to `/boot/config/plugins/compose.manager/projects/`. -3. Create a folder named `storybid`. -4. Upload `docker-compose.yml` and your completed `.env` into that folder. - -**Option B — SCP from your workstation** +### 19.1. Pull and start NPM as a separate Compose stack ```bash -# From your workstation -scp docker-compose.yml root@192.168.1.X:/boot/config/plugins/compose.manager/projects/storybid/ -scp .env root@192.168.1.X:/boot/config/plugins/compose.manager/projects/storybid/ +mkdir -p /mnt/user/appdata/npm/{data,letsencrypt} +cat > /mnt/user/appdata/npm/docker-compose.yml <<'EOF' +version: "3.9" +services: + npm: + image: jc21/nginx-proxy-manager:latest + restart: unless-stopped + ports: + - "80:80" + - "81:81" # Admin UI — close after first login + - "443:443" + volumes: + - /mnt/user/appdata/npm/data:/data + - /mnt/user/appdata/npm/letsencrypt:/etc/letsencrypt +EOF + +cd /mnt/user/appdata/npm +docker compose up -d ``` -### 3. Set explicit volume bind mounts - -Before starting, edit `docker-compose.yml` to replace Docker-managed volumes with -explicit host paths so Unraid can show them in the GUI (same change as step 4 of the -CLI method above): - -```yaml -volumes: - postgres_data: - driver: local - driver_opts: - type: none - o: bind - device: /mnt/user/appdata/storybid/postgres - - redis_data: - driver: local - driver_opts: - type: none - o: bind - device: /mnt/user/appdata/storybid/redis - - media_data: - driver: local - driver_opts: - type: none - o: bind - device: /mnt/user/appdata/storybid/uploads -``` - -Create the host directories via **Unraid → Tools → New Terminal**: +### 19.2. First-login admin password ```bash -mkdir -p /mnt/user/appdata/storybid/{postgres,redis,uploads} +# Default credentials — change these on first login. +echo "Email: admin@example.com" +echo "Password: changeme" ``` -### 4. Configure environment variables in the GUI +Open `http://192.168.1.50:81` in a browser. Sign in with the defaults above +— NPM forces a password change. Set a strong admin password and a real +email. -1. In the **Compose** tab, click the **storybid** project row. -2. Click **Edit** to open the compose file editor — verify paths look correct. -3. Click the **⚙ Environment** button (or edit the `.env` file directly via File - Manager). -4. Set every variable listed in the [Environment Variables Reference](#environment-variables-reference) - above. At minimum: +### 19.3. Add the proxy host (CLI-driven config — done once via the web UI) - | Key | Value | - |---|---| - | `NODE_ENV` | `production` | - | `PUBLIC_URL` | `https://bid.example.org` | - | `JWT_SECRET` | *(64-char random string — generate in Terminal with `openssl rand -hex 32`)* | - | `DATABASE_URL` | `postgresql://storybid:CHANGE_ME@db:5432/storybid` | - | `STRIPE_SECRET_KEY` | `sk_live_…` | - | `STRIPE_PUBLISHABLE_KEY` | `pk_live_…` | - | `STRIPE_WEBHOOK_SECRET` | `whsec_…` | - | `SMTP_HOST` | your SMTP server | - | `SMTP_USER` | sender address | - | `SMTP_PASS` | SMTP password | - | `EMAIL_FROM` | `Storybid ` | - -5. Click **Save**. - -### 5. Build and start the stack - -1. In the **Compose** tab, click **Up** (▶) next to **storybid**. -2. The compose manager opens a build log window. First build takes 3–5 minutes. -3. When all containers show **Running**, close the log. - -### 6. Run migrations via the terminal - -The GUI plugin does not yet have a built-in `exec` interface, so open a terminal for -this one step: - -**Unraid → Tools → New Terminal** - -```bash -docker exec storybid-server-1 npx prisma migrate deploy -``` - -> The container name format is `--1`. If yours differs, run -> `docker ps` to find the exact name. - ---- - -## Reverse Proxy (Nginx Proxy Manager) - -Storybid needs HTTPS for Stripe webhooks, PWA installation, and the camera API used -by the check-in page. **Nginx Proxy Manager** is the easiest option on Unraid. - -### Install Nginx Proxy Manager - -Search **"Nginx Proxy Manager"** in Community Applications and install it. Default -ports: HTTP 80, HTTPS 443, Admin UI 81. - -### Add a proxy host for the client +NPM does not currently expose a stable CLI for proxy hosts. Use the admin +UI on port 81 once: | Field | Value | |---|---| | Domain Names | `bid.example.org` | | Scheme | `http` | -| Forward Hostname / IP | Unraid LAN IP (e.g. `192.168.1.50`) | +| Forward Hostname / IP | `192.168.1.50` (the Unraid LAN IP) | | Forward Port | `8080` | -| SSL Certificate | Request via Let's Encrypt (built into the GUI) | -| Force SSL | ✅ | -| HTTP/2 Support | ✅ | +| Block Common Exploits | ✅ | +| Websockets Support | ✅ | +| **SSL tab → SSL Certificate** | Request a new SSL Certificate (Let's Encrypt) | +| **SSL tab → Force SSL** | ✅ | +| **SSL tab → HTTP/2 Support** | ✅ | +| Email | your contact address | +| Agree to Let's Encrypt TOS | ✅ | -### Add a proxy host for the API + WebSocket +Save. NPM provisions the cert in 30–60 seconds. The bidder app is now +reachable at `https://bid.example.org`. -Create a **second** proxy host (or add a location block) if your client and server are -on different subdomains, otherwise the single `bid.example.org` host handles both -because the Nginx config inside the client container already proxies `/api` and -`/socket.io` to `server:3001`. +### 19.4. Close the NPM admin port -If you prefer separate subdomains: +Once the proxy host is verified working, close port 81 to the internet. +Edit `/mnt/user/appdata/npm/docker-compose.yml` and remove the `81:81` +line, then: + +```bash +cd /mnt/user/appdata/npm && docker compose up -d +``` + +The admin UI is then only reachable from the LAN via SSH tunnel: + +```bash +ssh -L 8181:192.168.1.50:81 root@192.168.1.50 +# Then browse http://localhost:8181 from your workstation. +``` + +--- + +## 20. Register the Stripe webhook + +Now that `https://bid.example.org` serves real TLS: + +1. Go to (test or live mode — match + what your `.env` keys are for). +2. Click **+ Add endpoint**. +3. **Endpoint URL**: `https://bid.example.org/api/webhooks/stripe` +4. **Events to send**: select **Select events**, then check: + - `payment_intent.succeeded` + - `payment_intent.payment_failed` +5. Click **Add endpoint**. +6. On the resulting endpoint page, click **Reveal** under **Signing secret**. + Copy the value (starts with `whsec_…`). +7. Back on Unraid, paste it into `.env`: + + ```bash + cd /mnt/user/appdata/storybid/repo + nano .env + # Set: STRIPE_WEBHOOK_SECRET="whsec_..." + ``` + +8. Restart the server container so it picks up the new value: + + ```bash + docker compose restart server + ``` + +9. Back in the Stripe dashboard, click **Send test webhook** on the endpoint + page. Pick `payment_intent.succeeded`. The endpoint should respond + `200 OK` within a second. Confirm in: + + ```bash + docker compose logs server | grep -i webhook + ``` + +If you see `signature verification failed`, the secret doesn't match — +re-copy from the dashboard and restart. + +--- + +## 21. UniFi event-night network configuration + +This wiring is what makes Storybid survive a WAN outage during a live +auction. See also `ops/unifi-dns.md`. + +### 21.1. Local DNS record + +UniFi Network → **Settings → Networks → DNS Records** (older firmware: +**Settings → Profiles → DNS**) → **Create Entry**: | Field | Value | |---|---| -| Domain Names | `api.example.org` | -| Scheme | `http` | -| Forward Hostname / IP | `192.168.1.50` | -| Forward Port | `3001` | -| SSL | Let's Encrypt | -| WebSockets Support | ✅ | +| Type | `A` | +| Hostname | `auction.event.lan` | +| Value | `192.168.1.50` (Unraid LAN IP) | +| TTL | 60 | -Then update `PUBLIC_URL` and `CLIENT_URL` in `.env` accordingly. +Save. `LOCAL_HOSTNAME` in `.env` already matches. + +Verify from a device on the LAN: + +```bash +nslookup auction.event.lan 192.168.1.1 # router IP +# → 192.168.1.50 +``` + +### 21.2. Dedicated event SSID + +UniFi Network → **Settings → WiFi → Create New WiFi Network**: + +- Name: `GalaAuction` +- Password: shared on event signage / check-in +- Network: same VLAN as the server +- Band steering: ✅ (cleaner 5 GHz preference) +- Apply. + +### 21.3. Failover smoke test + +1. Join `GalaAuction` on a phone. +2. Open `https://bid.example.org` — it loads via WAN. +3. From the UniFi dashboard, **disable the WAN port** (or unplug the modem). +4. Reload the bidder page. The connectivity banner should turn yellow + (*"Local network — offline-capable"*) and the catalog still works. +5. Re-enable WAN. Banner returns to green within 5 seconds. + +### 21.4. UPS + +Plug **server, gateway, and APs** into a single UPS. The whole local +network must stay up if shore power blips, otherwise failover is moot. --- -## Register the Stripe Webhook +## 22. End-to-end smoke test -1. Go to **Stripe Dashboard → Developers → Webhooks → Add endpoint**. -2. Endpoint URL: `https://bid.example.org/api/webhooks/stripe` -3. Events to listen for: - - `payment_intent.succeeded` - - `payment_intent.payment_failed` -4. Click **Add endpoint**. -5. Copy the **Signing secret** (`whsec_…`) into `STRIPE_WEBHOOK_SECRET` in `.env`. -6. Restart the server container to pick up the new value: +Drive these from a real phone on the LAN. Tracking against the runbook in +`event-runbook/preflight.md`: - ```bash - # CLI - docker compose restart server +1. **Admin** — sign in at `https://bid.example.org/admin`, create one + `live` auction with one item, and one `silent` auction with one item + that closes 5 minutes from now. +2. **Bidder** — sign in via SMS OTP using a real phone number (Twilio + dashboard → **Verify → Logs** should show the sent OTP). +3. **Silent bid** — place a bid; another bidder outbids; outbid notification + arrives. +4. **Silent close** — wait for the timer; the high bid wins. +5. **Live bid** — open `/staff/auctioneer` on a tablet, activate the live + item, accept a paddle bid, sell it. +6. **Checkout** — go to `/checkout`, pay with Stripe test card + `4242 4242 4242 4242`, exp `12/34`, CVC `123`. Stripe webhook fires; + invoice flips to `paid`. +7. **Failover** — repeat step 21.3 mid-bid; bid is queued and syncs when + WAN returns. - # GUI — Compose tab → storybid → Restart - ``` +If every step works, flip Stripe from test to live (section 4.2), update +`STRIPE_*` keys in `.env`, restart the server, and re-issue the webhook +secret in live mode. --- -## Updating Storybid +## 23. Backups and disaster recovery -### CLI +### 23.1. Database snapshot script + +```bash +cat > /mnt/user/appdata/storybid/backups/snapshot.sh <<'EOF' +#!/bin/bash +set -euo pipefail +BACKUP_DIR=/mnt/user/appdata/storybid/backups +STAMP=$(date +%F-%H%M) +docker exec storybid-db-1 pg_dump -U storybid storybid \ + | gzip > "$BACKUP_DIR/storybid-$STAMP.sql.gz" +# Keep 14 days +find "$BACKUP_DIR" -name 'storybid-*.sql.gz' -mtime +14 -delete +EOF +chmod +x /mnt/user/appdata/storybid/backups/snapshot.sh +``` + +Schedule it via Unraid's `User Scripts` plugin **or** plain cron. CLI cron +on Unraid persists across reboots only if you write it to `go`: + +```bash +cat >> /boot/config/go <<'EOF' +# Storybid: nightly DB snapshot at 03:00 +echo "0 3 * * * /mnt/user/appdata/storybid/backups/snapshot.sh" | crontab - +EOF +``` + +Reboot the server once so the cron entry takes effect, or run the line +inside `cat < # e.g. v0.1.2 — never deploy off main blindly +docker compose build +docker compose up -d +docker compose exec server npx prisma db push --skip-generate +``` + +### 24.2. Rollback + +```bash +cd /mnt/user/appdata/storybid/repo +git checkout +docker compose build +docker compose up -d + +# If schema changed and the new release added required columns, +# restore from the most recent pre-update backup: +docker compose stop server +gunzip < /mnt/user/appdata/storybid/backups/storybid-PRE-UPGRADE.sql.gz \ + | docker exec -i storybid-db-1 psql -U storybid -d storybid +docker compose start server +``` + +> Always take a snapshot **immediately before** running `git pull` on the +> day of an event: +> +> ```bash +> /mnt/user/appdata/storybid/backups/snapshot.sh +> cp /mnt/user/appdata/storybid/backups/storybid-$(date +%F)*.sql.gz \ +> /mnt/user/appdata/storybid/backups/storybid-PRE-UPGRADE.sql.gz +> ``` + +--- + +## 25. Troubleshooting + +### Containers crash-loop on startup + +```bash +docker compose logs server | tail -50 +``` + +Common causes: + +- `DATABASE_URL` password mismatch — re-check `.env` and the `db` service + password in `docker-compose.yml`. +- `JWT_SECRET` blank — magic links can't sign; regenerate with + `openssl rand -hex 32`. +- `.env` has CRLF line endings — fix with `sed -i 's/\r$//' .env`. + +### Magic-link emails don't arrive + +```bash +docker compose logs server | grep -iE 'mail|smtp' +``` + +- 535 auth — wrong SMTP user/password. +- DNS lookup failure — Unraid host DNS broken; set `1.1.1.1` in + Settings → Network. +- Mail accepted but never delivered — DKIM/SPF not set. Verify the sending + domain in your provider's dashboard. + +### Stripe webhook returns 400 / 401 / 500 + +```bash +docker compose logs server | grep -i stripe +``` + +- `signature verification failed` — `STRIPE_WEBHOOK_SECRET` mismatch. + Re-copy from the dashboard and `docker compose restart server`. +- `endpoint timeout` — NPM not forwarding `/api/webhooks/stripe`. Test + manually: `curl -i https://bid.example.org/api/webhooks/stripe -X POST` + should return `400 missing-stripe-signature`, not `502`. + +### Twilio Verify "60200 — Invalid parameter" + +The phone number isn't in E.164 (`+15555551234`). Make sure the bidder UI +passes the country code; the dialer hint should default to `+1`. + +### Bidders can't reach `auction.event.lan` + +```bash +nslookup auction.event.lan 192.168.1.1 +``` + +If this fails: + +- The UniFi DNS record didn't save — re-add via section 21.1. +- The bidder device is using a public DNS (e.g. iCloud Private Relay) + instead of the gateway. UniFi Network → **Settings → Networks → LAN → + DNS Server** = `Auto`, and disable Private Relay on test devices. + +### Database disk fills up + +```bash +docker compose exec db psql -U storybid -d storybid -c " + SELECT pg_size_pretty(pg_database_size('storybid'));" +``` + +Trim audit logs older than the most recent event: + +```bash +docker compose exec db psql -U storybid -d storybid -c " + DELETE FROM \"AuditLog\" WHERE \"createdAt\" < NOW() - INTERVAL '180 days'; + VACUUM FULL;" +``` + +### Need to wipe and start over + +```bash +cd /mnt/user/appdata/storybid/repo +docker compose down -v # destroys named volumes +rm -rf /mnt/user/appdata/storybid/{postgres,redis,uploads}/* +mkdir -p /mnt/user/appdata/storybid/{postgres,redis,uploads} docker compose up -d --build -docker compose exec server npx prisma migrate deploy -``` - -### GUI - -1. Transfer the updated `docker-compose.yml` to the project folder (same path as - installation). -2. **Compose tab → storybid → Pull + Up** — this rebuilds images and restarts containers. -3. Open **Unraid → Tools → New Terminal** and run: - ```bash - docker exec storybid-server-1 npx prisma migrate deploy - ``` - ---- - -## Port Reference - -| Port | Service | Exposed to | -|---|---|---| -| `3001` | API server | Reverse proxy / LAN | -| `8080` | Client (Nginx) | Reverse proxy / LAN | -| `5432` | PostgreSQL | LAN only (close in production if not needed) | -| `6379` | Redis | LAN only (close in production if not needed) | - -To restrict Postgres and Redis to the Docker internal network only (recommended for -production), remove their `ports:` entries from `docker-compose.yml`: - -```yaml -# Remove or comment out these blocks: - db: - ports: - - "5432:5432" # ← remove - - redis: - ports: - - "6379:6379" # ← remove +docker compose exec server npx prisma db push --skip-generate +docker compose exec server npx tsx prisma/seed.ts ``` --- -## Troubleshooting +## Done -### Containers restart immediately - -```bash -docker compose logs server # check for DATABASE_URL or JWT_SECRET errors -docker compose logs db # check for volume permission issues -``` - -### Migration fails with "relation already exists" - -The database was already migrated. This is safe to ignore, or run: - -```bash -docker compose exec server npx prisma migrate status -``` - -### `STRIPE_SECRET_KEY is not configured` error in logs - -The `.env` file is not being loaded. Verify: - -- `.env` is in the same directory as `docker-compose.yml`. -- The `server` service in `docker-compose.yml` has `env_file: .env`. -- No trailing whitespace or Windows line endings in `.env` - (`dos2unix .env` fixes CRLF issues). - -### Media uploads return 404 - -Check that the `uploads` bind-mount directory exists and is writable: - -```bash -ls -la /mnt/user/appdata/storybid/uploads -chmod 755 /mnt/user/appdata/storybid/uploads -docker compose restart server -``` - -### Bidders can't connect via local LAN hostname - -1. Verify the DNS record resolves from a device on the event Wi-Fi: - ```bash - nslookup auction.event.lan - ``` -2. Confirm `LOCAL_HOSTNAME` in `.env` matches the DNS record exactly. -3. See [unifi-dns.md](./unifi-dns.md) for full UniFi DNS setup steps. - -### Compose Manager shows "project not found" - -The project directory must be inside `/boot/config/plugins/compose.manager/projects/`. -Files on `/mnt/user/` shares are not read by the plugin. Use SCP or File Manager to -place files in the correct location on `/boot`. +The server is now a clean, repeatable Storybid install. Pin this document +inside your operational runbook (`event-runbook/preflight.md` references +the same paths and commands). Re-run the smoke test before every event, +take a fresh DB snapshot the morning of, and you're set.