diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7a88c06..5904064 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,23 +1,35 @@ -# Push to main -> the NAS rebuilds Waters Boards. +# Push to main -> Waters Boards is rebuilt on the AWS Lightsail box. # -# Same shape as the checklist app's deploy: runs on the "nas" act_runner (in Docker on the -# NAS itself), copies server/ over the live directory, then recreates the container in place. +# Runs on the "nas" act_runner, which lives inside the church network and can reach the +# internet. (The Gitea server isn't reachable from outside, so AWS can't pull from it; the +# runner pushes to AWS instead.) It syntax-checks the code, rsyncs server/ and deploy/aws/ to +# /opt/waters-boards on the box over SSH, rebuilds in place and waits for the healthcheck. +# All of that is deploy/aws/deploy.sh, the same script you run from a laptop. # -# ONE-TIME NAS SETUP before the first run (see README.md › Deploy): -# 1. mkdir /volume2/docker/waters-boards and put a filled-in .env there -# 2. add "/volume2/docker/waters-boards" to container.valid_volumes in the act_runner -# config.yaml on the NAS, and restart the runner -# 3. point a reverse-proxy hostname (https) at port 3100 — Google sign-in needs https -# 4. set the repository variable DEPLOY_ENABLED=true (Settings ▸ Actions ▸ Variables). -# Until then this job is skipped, so pushes don't fail before the NAS is ready. +# ONE-TIME SETUP before the first run (see README.md › Deploy to AWS): +# 1. Make a deploy-only SSH key (e.g. ssh-keygen -t ed25519 -N '' -f deploy_key) and add the +# public half to /home/ubuntu/.ssh/authorized_keys on the box. +# 2. Add repository secrets (Settings ▸ Actions ▸ Secrets): +# DEPLOY_SSH_KEY the private key from step 1 +# DEPLOY_KNOWN_HOSTS the output of: ssh-keyscan -t ed25519 +# 3. Add repository variables (Settings ▸ Actions ▸ Variables): +# DEPLOY_HOST the box's static IP +# DEPLOY_ENABLED true +# Until DEPLOY_ENABLED is true this job is skipped, so pushes made before the box is +# ready don't show as failures. +# 4. The "nas" runner must be registered where it can see this repo (user- or instance-level, +# not only on the checklist repo). +# /opt/waters-boards/.env must already exist on the box. The deploy never creates or +# overwrites it, and never touches data/. -name: Deploy Waters Boards to NAS +name: Deploy Waters Boards to AWS on: push: branches: [main] paths: - 'server/**' + - 'deploy/aws/**' - '.gitea/workflows/deploy.yml' workflow_dispatch: @@ -27,55 +39,28 @@ jobs: runs-on: nas container: image: node:22-alpine - volumes: - # Mounted at the SAME path as on the host: docker-compose.yml binds ./data relative to - # the compose file and the daemon resolves that as a host path. (The docker socket is - # already mounted by act_runner — declaring it again breaks the job.) - - /volume2/docker/waters-boards:/volume2/docker/waters-boards steps: - name: Install tools - run: apk add --no-cache docker-cli docker-cli-compose rsync + # deploy.sh is a bash script and needs node (already in the image) for its syntax check. + run: apk add --no-cache bash openssh-client rsync - name: Checkout uses: actions/checkout@v4 - - name: Check the deploy directory is set up + - name: Set up the SSH key + # Secrets go in through env, not pasted into the script text. + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} run: | set -eu - DEST=/volume2/docker/waters-boards - test -f "$DEST/.env" || { echo "REFUSING: $DEST/.env is missing — see README.md › Deploy"; exit 1; } + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts - - name: Copy server files over the live directory - run: | - set -eu - # data/ holds the live database, uploads and backups; .env holds the secrets. - # Neither is in git and neither may ever be written here. No --delete. - rsync -a \ - --exclude 'data/' \ - --exclude '.env' \ - --exclude 'node_modules/' \ - server/ /volume2/docker/waters-boards/ - - - name: Verify the live data and credentials survived - run: | - set -eu - test -f /volume2/docker/waters-boards/.env || { echo "ABORT: .env is gone"; exit 1; } - echo ".env intact"; ls -la /volume2/docker/waters-boards/data 2>/dev/null || echo "(no data/ yet — first deploy)" - - - name: Rebuild the container in place - # No 'down' first: an interrupted deploy then leaves the old container serving. - run: docker compose -f /volume2/docker/waters-boards/docker-compose.yml up -d --build --remove-orphans - - - name: Healthcheck - run: | - set -eu - for i in $(seq 1 20); do - S=$(docker inspect --format '{{.State.Health.Status}}' waters-boards 2>/dev/null || echo starting) - [ "$S" = "healthy" ] && { echo "container healthy"; exit 0; } - sleep 3 - done - echo "container did not report healthy in time:" - docker ps --filter name=waters-boards --format '{{.Names}} {{.Status}}' - docker logs --tail 30 waters-boards - exit 1 + - name: Deploy + run: BOARDS_HOST="ubuntu@${{ vars.DEPLOY_HOST }}" bash deploy/aws/deploy.sh diff --git a/README.md b/README.md index 01826da..611ec81 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ A self-hosted work-management app for the Waters Church team: boards, groups, items, statuses, people, dates, updates and automations. It covers the parts of monday.com the team uses and -runs on the NAS beside the checklist app, so there's no monthly subscription. +runs on a small AWS Lightsail server for a few dollars a month, covered by the church's AWS +credits for now. It's one Node server (Express + SQLite) and a front end with no build step. It runs on desktop browsers and on phones, and a phone can install it to the home screen like an app. @@ -48,14 +49,13 @@ Data goes to `server/data/` (`boards.db`, `uploads/`, `backups/`). The folder is ## Set up Google sign-in (once) -Google only allows sign-in from **https** addresses (or `localhost`), so the app needs a -hostname behind the NAS reverse proxy, for example `https://boards.watersmedia.org`, set up the -same way as `check.watersmedia.org`. +Google only allows sign-in from **https** addresses (or `localhost`), so the app lives at +**`https://boards.watersmedia.org`**, behind Caddy on the AWS box (see *Deploy to AWS* below). 1. Go to and sign in with the church Google Workspace admin account. 2. Create a project (e.g. "Waters Boards"). -3. **APIs & Services → OAuth consent screen**: choose **Internal**, so only - `@waterschurch.org` accounts can ever use it. Fill in the app name and support email, then save. +3. **APIs & Services → OAuth consent screen**: choose **Internal**, so only accounts in the + church's Google Workspace can ever use it. Fill in the app name and support email, then save. 4. **APIs & Services → Credentials → Create credentials → OAuth client ID** - Application type: **Web application** - Authorized JavaScript origins: `https://boards.watersmedia.org` (and `http://localhost:3100` for local testing) @@ -70,32 +70,95 @@ Google sign-in is ever misconfigured. --- -## Deploy to the NAS +## Deploy to AWS (Lightsail) -This follows the same pattern as the checklist app: Docker on the NAS, and Gitea Actions -redeploys on every push to `main` that touches `server/`. +Waters Boards runs on one AWS Lightsail server. Docker runs two containers there: the app, and +Caddy in front of it. Caddy gets and renews the Let's Encrypt certificate itself, so TLS ends at +Caddy and the app speaks plain HTTP on the private Docker network. Only ports 80 and 443 are +public. -**One-time setup on the NAS** (`10.1.1.5`): +**The box:** Ubuntu 24.04, 2 GB RAM, `us-east-1`, a static IPv4 address, Docker Engine with the +compose plugin. Lightsail snapshots the disk every day at 07:00 UTC. -```bash -mkdir -p /volume2/docker/waters-boards -cp server/.env.example /volume2/docker/waters-boards/.env # then fill it in +**Folder on the box** (owned by `ubuntu`): + +``` +/opt/waters-boards/ + .env settings and secrets. Made by hand once, never in git + data/ database, uploads, nightly backups + Caddyfile copied from deploy/aws/ + docker-compose.yml copied from deploy/aws/ + server/ the app source, copied from this repo's server/ ``` -1. In the act_runner `config.yaml` on the NAS, add `/volume2/docker/waters-boards` to - `container.valid_volumes`, then restart the runner. The runner refuses host mounts that - aren't listed there. -2. Add a reverse-proxy rule: `https://boards.watersmedia.org` → `http://localhost:3100`. - Allow WebSocket/streaming headers, because the live updates use a long-lived HTTP response. -3. In the Gitea repo, set **Settings → Actions → Variables → `DEPLOY_ENABLED` = `true`**. Until it's - set, the deploy job is skipped, so pushes made before the NAS was ready don't show as failures. -4. Push to `main`, or run the workflow by hand from the Gitea Actions tab. +**One-time setup** -The workflow (`.gitea/workflows/deploy.yml`) never writes `data/` or `.env`. It rebuilds the -container in place without taking it down first, and fails loudly if `.env` is missing. +1. **DNS.** `watersmedia.org` is served by the church's own nameservers (`ns1` and + `ns2.waterschurch.org`), so there's no registrar panel to use. The zone is *not* in the + `watersch` cPanel account; it belongs to whichever cPanel/WHM account owns `watersmedia.org` + (its SOA contact is `nick@waterschurch.org`). In that account's **Zone Editor**, add an `A` + record `boards` → the box's static IP, TTL 300. Caddy can't get a certificate until that + record resolves, and ports 80 and 443 must be open in the Lightsail firewall. +2. **`.env`.** Copy `server/.env.example` to `/opt/waters-boards/.env` and fill it in. The deploy + script refuses to run without it, so do this from your laptop before the first deploy: -**Manual deploy**, without Gitea: copy `server/` (minus `data/`, `.env` and `node_modules/`) to the -NAS folder, then run `docker compose up -d --build` there. + ```bash + scp server/.env.example boards-aws:/opt/waters-boards/.env + ssh boards-aws nano /opt/waters-boards/.env + ``` + + Set `ADMIN_EMAILS` and `ADMIN_PASSWORD`. `ADMIN_PASSWORD` is the break-glass login until + Google sign-in is configured. Add `GOOGLE_CLIENT_ID` once you have it (see above). +3. **First deploy**, as below. When it finishes, should load. + +**Deploy from a laptop.** Needs the `boards-aws` SSH alias (user `ubuntu`) in `~/.ssh/config`. +From the repo root: + +```bash +deploy/aws/deploy.sh +``` + +It checks the server code for syntax errors, confirms `.env` exists on the box, copies `server/`, +`docker-compose.yml` and `Caddyfile` over, rebuilds the containers in place, waits for the +healthcheck, and prints what `https://boards.watersmedia.org/healthz` returns. Set `BOARDS_HOST` +to deploy to a host other than `boards-aws`. + +**Deploy from Gitea Actions.** The Gitea server is only reachable from inside the church network, +so AWS can't pull from it. Instead the `nas` runner, which is inside the network and can reach +the internet, runs the same `deploy.sh` and pushes to the box over SSH. The workflow +(`.gitea/workflows/deploy.yml`) runs on every push to `main` that touches `server/` or +`deploy/aws/`, or by hand from the Gitea Actions tab. One-time setup: + +1. Make a deploy-only SSH key. Add its public half to `/home/ubuntu/.ssh/authorized_keys` on + the box. +2. In the Gitea repo, **Settings → Actions → Secrets**, add: + - `DEPLOY_SSH_KEY`: the private key. + - `DEPLOY_KNOWN_HOSTS`: the output of `ssh-keyscan -t ed25519 `. +3. In **Settings → Actions → Variables**, add `DEPLOY_HOST` (the static IP) and + `DEPLOY_ENABLED` = `true`. Until `DEPLOY_ENABLED` is `true`, the deploy job is skipped, so + pushes made before the box was ready don't show as failures. +4. The `nas` runner must be registered where it can see this repo (user- or instance-level, not + only on the checklist repo). + +**What a deploy never touches:** `data/` and `.env`. It deletes nothing on the box. It rebuilds +without taking the running container down first, so a failed build leaves the old version +serving. + +**Restore** + +- *From a Lightsail snapshot* (the whole box): in the Lightsail console, create a new instance + from the latest snapshot, then move the static IP to it. The folder, `.env`, data and SSH host + key all come back as they were, so nothing else changes. +- *From `data/backups/`* (the database only): `data/` is owned by root, hence `sudo`. + + ```bash + ssh boards-aws + cd /opt/waters-boards + docker compose stop boards + sudo rm -f data/boards.db-wal data/boards.db-shm + sudo cp data/backups/boards-YYYY-MM-DD.db data/boards.db + docker compose start boards + ``` --- @@ -128,9 +191,11 @@ Inbox, Search). Pickers open as bottom sheets. ## Backups - Every night after 2am the app writes `data/backups/boards-YYYY-MM-DD.db` and keeps 14. -- For off-site copies, back up the whole `data/` folder (database + `uploads/`) the same way - the checklist data is backed up. -- To restore, stop the container, copy a snapshot over `data/boards.db`, and start it again. +- Lightsail takes an automatic snapshot of the whole disk every day and keeps 7. That covers + `data/` (database + `uploads/`), `.env` and everything else on the box. +- To restore a database snapshot, follow *Restore* under *Deploy to AWS* above. Don't just copy + the file over `boards.db`: the database runs in WAL mode, and a stale `boards.db-wal` beside a + restored file corrupts it. --- @@ -155,4 +220,8 @@ server/ js/item.js Item panel: updates, info, files, activity js/kanban.js · calendar.js · timeline.js · chart.js js/pages.js Home, My work, Inbox, Search, Team, Admin +deploy/aws/ + docker-compose.yml The app and Caddy containers, as run on the Lightsail box + Caddyfile Caddy config: HTTPS for boards.watersmedia.org, proxy to the app + deploy.sh Deploys from a laptop or Gitea: check, copy, rebuild, wait for healthy ``` diff --git a/deploy/aws/Caddyfile b/deploy/aws/Caddyfile new file mode 100644 index 0000000..9a1d45a --- /dev/null +++ b/deploy/aws/Caddyfile @@ -0,0 +1,20 @@ +# Caddy in front of Waters Boards. +# +# Caddy gets and renews the Let's Encrypt certificate by itself. That only works while +# the DNS A record for boards.watersmedia.org points at this box and ports 80 and 443 +# are open in the Lightsail firewall. + +{ + # Let's Encrypt sends certificate expiry notices here. + email michael@waterschurch.org +} + +boards.watersmedia.org { + encode zstd gzip + + reverse_proxy boards:3100 { + # Required: send every write straight through. Without it Caddy can buffer the + # server-sent events stream at /api/events and live updates stop arriving. + flush_interval -1 + } +} diff --git a/deploy/aws/deploy.sh b/deploy/aws/deploy.sh new file mode 100755 index 0000000..a18f33a --- /dev/null +++ b/deploy/aws/deploy.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Deploy Waters Boards to the AWS Lightsail box. +# +# Usage, from the repo root: +# deploy/aws/deploy.sh +# +# Target host comes from BOARDS_HOST (default: the "boards-aws" SSH alias). +# Never writes or deletes data/ or .env on the box. Uses ssh BatchMode, so it never prompts. + +set -euo pipefail + +HOST="${BOARDS_HOST:-boards-aws}" +REMOTE_DIR=/opt/waters-boards +SSH_OPTS=(-o BatchMode=yes) + +cd "$(dirname "$0")/../.." + +step() { printf '\n==> %s\n' "$*"; } +remote() { ssh "${SSH_OPTS[@]}" "$HOST" "$@"; } + +step "1/7 Syntax-check the server code" +node --check server/server.js +for f in server/src/*.js; do + node --check "$f" +done +echo "syntax OK" + +step "2/7 Check $HOST:$REMOTE_DIR/.env exists" +if ! remote "test -f $REMOTE_DIR/.env"; then + echo "ABORT: $REMOTE_DIR/.env is missing on $HOST (or $HOST is unreachable)." >&2 + echo "Create it once by hand from server/.env.example. This script never creates or overwrites it." >&2 + echo "See README.md > Deploy to AWS (Lightsail)." >&2 + exit 1 +fi +echo ".env found" + +step "3/7 Copy server/ to $HOST:$REMOTE_DIR/server/ (no data/, .env or node_modules/, no --delete)" +rsync -a -e "ssh ${SSH_OPTS[*]}" \ + --exclude 'data/' --exclude '.env' --exclude 'node_modules/' \ + server/ "$HOST:$REMOTE_DIR/server/" + +step "4/7 Copy docker-compose.yml and Caddyfile to $HOST:$REMOTE_DIR/" +# --inplace: the Caddyfile is bind-mounted as a single file. A normal rsync replaces it with a +# new file, and the running container would keep reading the old one. +rsync -a --inplace -e "ssh ${SSH_OPTS[*]}" \ + deploy/aws/docker-compose.yml deploy/aws/Caddyfile "$HOST:$REMOTE_DIR/" + +step "5/7 Rebuild and restart in place (no 'down' first: a failed build leaves the old container serving)" +remote "cd $REMOTE_DIR && docker compose up -d --build --remove-orphans" +# Caddy doesn't watch its config. Reload so a changed Caddyfile takes effect without a restart. +remote "cd $REMOTE_DIR && docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile" \ + || echo "WARNING: caddy reload failed; check: docker logs waters-caddy" >&2 + +step "6/7 Wait for the waters-boards container to report healthy (up to 20 x 3 s)" +healthy=0 +for i in $(seq 1 20); do + status=$(remote "docker inspect --format '{{.State.Health.Status}}' waters-boards" 2>/dev/null || echo starting) + echo " try $i/20: $status" + if [ "$status" = "healthy" ]; then + healthy=1 + break + fi + sleep 3 +done +if [ "$healthy" -ne 1 ]; then + echo "ABORT: waters-boards did not report healthy in time." >&2 + remote "docker ps" || true + remote "docker logs --tail 30 waters-boards" || true + exit 1 +fi +echo "container healthy" + +step "7/7 Check the public URL (informational only; DNS or the certificate may not be ready yet)" +code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://boards.watersmedia.org/healthz || true) +echo "https://boards.watersmedia.org/healthz -> HTTP ${code:-000}" + +echo +echo "Deploy finished." diff --git a/deploy/aws/docker-compose.yml b/deploy/aws/docker-compose.yml new file mode 100644 index 0000000..9cf2bab --- /dev/null +++ b/deploy/aws/docker-compose.yml @@ -0,0 +1,45 @@ +# Waters Boards on the AWS Lightsail box. +# +# This file is copied to /opt/waters-boards/ on the box (deploy/aws/deploy.sh does it), and +# sits there next to: +# .env secrets, created by hand once, never overwritten by a deploy +# data/ database, uploads and nightly backups, never touched by a deploy +# Caddyfile the reverse-proxy config from this folder +# server/ the app source, rsynced from ../../server +# "build: ./server" and "./data" below are relative to that folder, not to this repo. + +services: + boards: + build: ./server + image: waters-boards:latest + container_name: waters-boards + restart: unless-stopped + env_file: + - ./.env + volumes: + # Database (boards.db), uploaded files and nightly backups. + - ./data:/app/data + # Reachable only from Caddy on the compose network. No "ports": the app is never public. + expose: + - "3100" + + caddy: + image: caddy:2.10-alpine + container_name: waters-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + # Certificates and Caddy's state. Keep these volumes, or Caddy has to re-issue the + # certificate on every restart and Let's Encrypt will rate-limit it. + - caddy_data:/data + - caddy_config:/config + depends_on: + - boards + +volumes: + caddy_data: + caddy_config: diff --git a/server/.env.example b/server/.env.example index 71dd209..4a2a6b0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,8 +6,9 @@ PORT=3100 # See README.md › "Set up Google sign-in". Leave empty to disable Google sign-in. GOOGLE_CLIENT_ID= -# Only Google accounts on these domains can sign in (comma-separated). -ALLOWED_DOMAINS=waterschurch.org +# Only Google accounts on these domains can sign in (comma-separated). Both church +# domains are Google Workspace domains, so both are allowed by default. +ALLOWED_DOMAINS=waterschurch.org,waters.church # These people are always admins (comma-separated). The very first person to sign in # also becomes an admin. diff --git a/server/docker-compose.yml b/server/docker-compose.yml deleted file mode 100644 index 0979516..0000000 --- a/server/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - boards: - build: . - image: waters-boards:latest - container_name: waters-boards - restart: unless-stopped - ports: - # Host port : container port. 3000 is the checklist app, so this uses 3100. - - "3100:3100" - env_file: - - .env - volumes: - # Database (boards.db), uploaded files and nightly backups. - - ./data:/app/data