diff --git a/.env.example b/.env.example index 3fb730d5..3297ebcf 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,15 @@ ADMIN_PASSWORD="your-secure-password-here" # AWS_REGION="auto" # AWS_ACCESS_KEY_ID="" # AWS_SECRET_ACCESS_KEY="" + +# Generic VPS deployment (optional) — targets the npm run vps:* and +# npm run data:* commands at a host reachable over plain ssh instead of +# Fly.io. For a server managed by scripts/vps-deploy.sh this one line is all +# that's needed (the rest is discovered from the server): +# DEPLOY_HOST="root@my-site.example.com" +# +# Only for hand-managed setups without that script (bare node, own compose — +# nothing to discover), or as overrides: +# RESTART_CMD="docker restart editable" +# REMOTE_EXEC="docker exec editable" +# HOST_DATA_DIR="/path/to/checkout/data" diff --git a/Dockerfile b/Dockerfile index 7cb87ed7..3bc4382e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax = docker/dockerfile:1 ARG NODE_VERSION=24.14.0 -FROM node:${NODE_VERSION}-slim as base +FROM node:${NODE_VERSION}-slim AS base LABEL fly_launch_runtime="Node.js" @@ -9,18 +9,28 @@ WORKDIR /app ENV NODE_ENV="production" -# Build stage -FROM base as build +# Install dependencies before copying application code so this layer stays +# reusable until package.json or package-lock.json changes. +FROM base AS dependencies COPY --link .npmrc package-lock.json package.json ./ RUN npm ci --include=dev -COPY --link . . - -RUN mkdir /data && npm run build +# Produce a runtime-only dependency tree from the same clean install. This +# stage deliberately does not depend on application source, so code-only +# changes cannot invalidate the final image's node_modules layer. +FROM dependencies AS production-dependencies RUN npm prune --omit=dev +# Build stage +FROM dependencies AS build + +COPY --link . . + +RUN mkdir /data && npm run build && \ + mv /app/node_modules /build-dependencies + # Final stage FROM base @@ -33,14 +43,19 @@ ARG LITESTREAM_VERSION=0.5.14 ADD https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-${LITESTREAM_VERSION}-linux-x86_64.deb /tmp/litestream.deb RUN dpkg -i /tmp/litestream.deb && rm /tmp/litestream.deb -COPY --from=build /app /app +# Keep the large dependency tree separate from the frequently changing +# application so registries and Docker's local image store can share it across +# code-only releases. The build stage moved its development dependencies out +# of /app, making the broad application copy below safe and omission-proof. +COPY --link --from=production-dependencies /app/node_modules /app/node_modules +COPY --link --from=build /app /app # Copy .sqliterc for convenient sqlite3 CLI usage -COPY --from=build /app/.sqliterc /root/.sqliterc +COPY --link --from=build /app/.sqliterc /root/.sqliterc RUN mkdir -p /data VOLUME /data EXPOSE 3000 -CMD ["node", "/app/scripts/start-app.js"] \ No newline at end of file +CMD ["node", "/app/scripts/start-app.js"] diff --git a/README.md b/README.md index 6e403ff1..7c5ea7b0 100644 --- a/README.md +++ b/README.md @@ -636,7 +636,7 @@ From here it's just iteration: add calls to action, give editors control over th Deploy your local site to a public URL in a few steps. -Editable runs on any VPS — all you need is Node.js, and the included `Dockerfile` works with any platform that supports Docker. The repository ships ready-made for [Fly.io](https://fly.io): install [flyctl](https://fly.io/docs/flyctl/install/), then sign in (opens your browser; creates a free account if you don't have one): +Editable runs on any VPS — all you need is Node.js, and the included `Dockerfile` works with any platform that supports Docker (see [Deploy to a VPS](#deploy-to-a-vps-experimental)). The repository ships ready-made for [Fly.io](https://fly.io), which remains the recommended default: machines scale to zero and wake in well under a second, so a personal site costs next to nothing to run. Install [flyctl](https://fly.io/docs/flyctl/install/), then sign in (opens your browser; creates a free account if you don't have one): ```sh fly auth login @@ -669,6 +669,8 @@ fly secrets set \ ADMIN_PASSWORD='pick-a-strong-password' ``` +`ORIGIN` must exactly match the URL you use in the browser, including the scheme and subdomain (for example, `https://example.com` and `https://www.example.com` are different origins). An incorrect value causes login and other write requests to fail with `403 Forbidden` before the password is checked. Update this secret whenever you switch to a custom domain, and access the site through that canonical URL. + Optionally set `ASSET_GRACE_PERIOD_DAYS` (default 7): unreferenced asset files are kept on disk this many days after losing their last reference. This is also the safe window for rolling back a database backup against the live assets folder without ending up with dead image references. Deploy. The first deploy also creates the 1 GB `data` volume declared under `[mounts]` in `fly.toml`: @@ -692,11 +694,68 @@ fly open Because each checkout manages exactly one app (see [Your site is your repo](#your-site-is-your-repo)), the target always comes from `fly.toml` — there's no app name to get wrong. If you ever do need to address a different app (say, a staging copy), every `fly` command and data script accepts `-a ` as an explicit override. +### Deploy to a VPS (experimental) + +Editable runs on any amd64 host with Docker — a DigitalOcean droplet, a Hetzner or Nodion VPS. One command takes a fresh Ubuntu server to a running site with TLS. Create the server with your ssh key installed, point your domain's A record at its IP, then: + +```sh +npm run vps:deploy -- root@203.0.113.10 my-site.example.com +``` + +The first run provisions the server — Docker, [Caddy](https://caddyserver.com) as the TLS-terminating reverse proxy, swap on machines with less than 2 GB RAM — asks you for an admin password, builds the Docker image locally, streams it over ssh, and starts the site. The server never needs access to your git repository or the memory to run a build. Your content lives in `/data` on the server — the same path Fly.io mounts and the same path inside the container; deploys replace the container and never touch that folder. + +After the first deploy, add one line to your **local** `.env` — this is your checkout's deployment identity, playing the role `fly.toml` plays for Fly.io. Everything else (container name, data path) is read from the server: + +```sh +DEPLOY_HOST="root@203.0.113.10" # who you ssh in as +``` + +With `DEPLOY_HOST` set, no command needs the server address anymore. Ship an update — build, stream, replace the container, health-check: + +```sh +npm run vps:deploy +``` + +See what's running and what you can roll back to — each image tag is a git commit, and the last three are kept on the server (a deploy from a working tree with uncommitted changes is tagged `-dirty` to keep experiments distinguishable from committed states): + +```sh +npm run vps:status + +# → Running: editable:939761c (Up 5 minutes) — https://my-site.example.com +# +# Images on the server (newest first): +# 939761c 2026-07-20 21:35 Add env var management ← running +# 502dcdf 2026-07-20 20:43 Harden deploy script +``` + +Roll back a bad deploy by starting a previous image: + +```sh +npm run vps:deploy -- --tag +``` + +And every `npm run data:*` command ([Backup, sync & recovery](#backup-sync--recovery)) targets the VPS too (`fly.toml` is ignored; remove or comment `DEPLOY_HOST` to target Fly.io again). Pull, push, backups, restores, point-in-time recovery — the whole toolbox behaves the same, including disaster recovery from the backup bucket on a fresh volume. + +The server keeps its own `.env` (the equivalent of `fly secrets`) — inspect and change it with the `env` command: + +```sh +npm run vps:env # show it (secrets masked) +npm run vps:env -- set BUCKET_NAME=my-backup AWS_REGION=auto +npm run vps:env -- set ADMIN_PASSWORD # no value = prompted, kept out of shell history +npm run vps:env -- unset BUCKET_NAME +``` + +`set` and `unset` restart the app so the change takes effect immediately. For [automated backups](#automated-backups-optional), the `BUCKET_*` / `AWS_*` secrets belong in the server's `.env` — the first deploy copies them from your local `.env` if present; after that, changes are explicit via `vps:env`. + +**Doing it by hand instead:** the script is optional — `docker-compose.yml` runs on any docker host. Clone your site on the server, `cp .env.example .env` and set `ADMIN_PASSWORD` and `ORIGIN="https://my-site.example.com"`, then `docker compose up -d --build`. The app listens on `127.0.0.1:3000`; put a reverse proxy with TLS in front (with Caddy that's the whole config: `my-site.example.com { reverse_proxy 127.0.0.1:3000 }`). Ship updates with `git pull && docker compose up -d --build`. For the data commands, a hand-managed setup has nothing to auto-discover, so set the explicit keys alongside `DEPLOY_HOST` in your local `.env`: `RESTART_CMD="docker restart editable"`, `REMOTE_EXEC="docker exec editable"`, and `HOST_DATA_DIR` pointing at the `./data` bind mount as an absolute path (without the script, the container keeps the default name `editable`). + +What Fly.io still does for you that a VPS doesn't: scale-to-zero with sub-second wake-ups (a VPS runs — and bills — around the clock), TLS and anycast routing without a reverse proxy, and volume snapshots. The VPS path trades that for a fixed monthly price and no platform dependency. + ## Backup, sync & recovery Your whole site lives in one folder — pull it, push it, snapshot it, roll it back. -That folder is `data/`: an SQLite database (`db.sqlite3`) and uploaded assets (`assets/`). Locally it defaults to `./data`; on Fly.io it's a persistent volume at `/data`. The data commands move that folder between your machine, your deployment, and — optionally — a backup bucket. The complete toolbox: +That folder is `data/`: an SQLite database (`db.sqlite3`) and uploaded assets (`assets/`). Locally it defaults to `./data`; on Fly.io it's a persistent volume at `/data`; on a VPS it's `/data` on the host, bind-mounted into the container at the same path. The data commands move that folder between your machine, your deployment, and — optionally — a backup bucket. The complete toolbox: - **npm run data:pull** — Copy the live site's data to your machine - **npm run data:push [-- --yes]** — Replace the live site's data with your local state — guarded, undoable diff --git a/VPS_DEPLOY_SPEC.md b/VPS_DEPLOY_SPEC.md new file mode 100644 index 00000000..e86e2e42 --- /dev/null +++ b/VPS_DEPLOY_SPEC.md @@ -0,0 +1,110 @@ +# VPS deploy script — specification + +Spec for `scripts/vps-deploy.sh`: a single local script that takes a fresh Ubuntu VPS (e.g. a DigitalOcean droplet) from zero to a running, TLS-terminated Editable site, and afterwards ships code updates to the same box. Modeled on the Writebook/ONCE installer experience, without Kamal or any registry. + +This file is the working spec for the feature. Once implemented and stable, fold the design decisions into `ARCHITECTURE.md` and the user-facing instructions into README → Deploy to a VPS. + +## Goals + +- One command against a fresh VPS sets up everything: `./scripts/vps-deploy.sh root@203.0.113.10 my-site.example.com` +- The same command run again ships a code update (the script detects what's needed; no separate setup/deploy modes for the user to learn) +- The image is built locally and streamed over ssh — the server never needs git access, npm, or the memory to run a Vite build +- The only state on the server that matters is the bind-mounted data directory; the container is disposable and replaced on every deploy +- A destroyed server is recovered by running the script against a fresh one (boot-time disaster recovery from the backup bucket already exists in `scripts/run-cloud-boot.js`) + +## Non-goals (v1) + +- Zero-downtime deploys — a few seconds of downtime while the container is replaced is accepted +- Multiple servers, multiple apps per server, deployment locking +- ARM servers — the Dockerfile pins the Litestream `.deb` to `linux-x86_64`, so v1 requires an amd64 VPS and builds with `--platform linux/amd64` (revisit by making the Litestream download arch-aware) +- Provisioning the VPS itself or DNS — the user creates the droplet (with their ssh key) and points the domain's A record at it first, like the Writebook flow + +## Design decisions + +**Build locally, stream over ssh, no registry.** `docker buildx build --platform linux/amd64` with the image tagged `editable:`, piped through gzip into `ssh 'gunzip | docker load'`. This keeps the server free of build tooling and repo access, and needs no registry account. + +**Host-level Caddy, not a Caddy container.** The app already binds `127.0.0.1:3000` and the README already documents the two-line Caddyfile. Caddy is installed from its apt repo and configured with exactly that config; certificates are Caddy's problem. No compose networking, no cert volumes. + +**Compose stays the runtime, switched from `build:` to `image:`.** `docker-compose.yml` gains `image: 'editable:${IMAGE_TAG:-local}'` and drops `build: .` as the on-server path. Local from-source runs use `docker compose up --build` explicitly (compose builds the `image:` tag when `--build` is passed), so the local workflow documented today keeps working. The script deploys with `IMAGE_TAG= docker compose up -d --remove-orphans`; compose sees the tag change and replaces the container, leaving the `./data` bind mount untouched. + +**Server layout.** One site per server, so nothing needs a per-site name — the layout separates the precious from the disposable: + +``` +/data the persistent site data — the only thing worth backing + up; same path as Fly's volume mount and the in-container + path (bind mount /data → /data) +/srv/editable/ +├── docker-compose.yml uploaded by the script on every deploy +├── .env created on first run, never overwritten +└── .deploy_env marker: IMAGE_TAG, CONTAINER_NAME, HOST_DATA_DIR +``` + +Everything under `/srv/editable` is recreatable by the next deploy. The container is plain `editable` — identical to a hand-managed compose setup, so both flows converge on one naming scheme. The Caddy vhost lives in `/etc/caddy/sites/editable.caddy`, imported from the main Caddyfile (which stays untouched otherwise). The compose volume line is `'${HOST_DATA_DIR:-./data}:/data'`: local and hand-managed runs keep `./data` next to the compose file, the script pins `/data` via `.deploy_env`. + +**Secrets.** On first run the script prompts for `ADMIN_PASSWORD` (offering a generated one), sets `ORIGIN=https://`, and writes the server's `.env`. Backup-bucket credentials (`BUCKET_NAME`, `AWS_*`) are copied from the local `.env` if present. That first-run bootstrap is the only implicit sync: afterwards the server's `.env` is authoritative and changes only through the explicit `env` command (`env` show / `env set KEY=VALUE` / `env set KEY` with hidden prompt / `env unset KEY`), which rewrites the file and recreates the container so the change takes effect — fly-secrets style, nothing moves without being named. Secrets are never passed as command-line arguments to remote shells. + +**Idempotent provisioning, not a setup mode.** Every run executes the same phases; each phase checks before it acts (Docker installed? Caddy installed? Caddyfile current? `.env` present?). First run does everything; later runs fall through to the deploy phase in seconds. + +**Dirty builds are labeled, not versioned.** A build from a working tree with uncommitted or untracked changes is tagged `-dirty`, so the image list and rollbacks can't mistake it for the committed state. Deploying dirty again reuses the tag — commits are the rollback anchors; dirty states are ephemeral by nature. + +**Rollback = redeploy an old tag + existing data restore.** The script keeps the last 3 image tags on the server (older ones pruned after a successful deploy). `./scripts/vps-deploy.sh --tag ` starts that image instead of building. Content rollback is out of scope — that's `npm run data:restore`, which already works against the VPS via `DEPLOY_HOST`. + +**Health check gates success.** After `compose up`, the script polls `127.0.0.1:3000` over ssh (curl, ~30 s budget). On failure it prints the container logs and exits non-zero; it does not auto-rollback in v1. + +**`.env` bridge to the data toolbox.** After a successful first deploy the script prints the exact `DEPLOY_HOST` / `RESTART_CMD` / `REMOTE_EXEC` / `HOST_DATA_DIR` block for the local `.env` (values it already knows), so `npm run data:*` works immediately. + +## Script interface + +``` +./scripts/vps-deploy.sh first deploy, or explicit target (always works) +./scripts/vps-deploy.sh deploy to DEPLOY_HOST (npm run vps:deploy) +./scripts/vps-deploy.sh status running tag + rollback tags (npm run vps:status) +./scripts/vps-deploy.sh env show the server's env, masked (npm run vps:env) +./scripts/vps-deploy.sh env set KEY=VALUE … set env vars and restart the app +./scripts/vps-deploy.sh env set KEY prompt for the value (hidden input) +./scripts/vps-deploy.sh env unset KEY … remove env vars and restart the app + +Options: + --tag deploy an already-uploaded image tag (rollback) instead of building + --yes skip confirmation prompts (except the first-run password prompt) +``` + +**Addressing.** `DEPLOY_HOST` in the local `.env` is the checkout's entire deployment identity — the same key the data toolbox uses, playing the role `fly.toml` plays for Fly.io. The user adds it by hand from the line the first deploy prints (deliberately not auto-written, so it stays transparent where commands are targeted). Everything else is read from the server via the `/srv/editable/.deploy_env` marker: the deploy script reads the domain from `ORIGIN` in the server's `.env`, and `data.sh` takes `REMOTE_EXEC`, `RESTART_CMD`, and `HOST_DATA_DIR` from the marker's `CONTAINER_NAME` and `HOST_DATA_DIR`. Explicit values always override, and setups without the marker (bare node, hand-managed compose) keep configuring the data toolbox explicitly. The explicit ` ` form is what works before any of this exists. + +`` is typically `root@` on a fresh droplet; any sudo-capable user works. ssh key access is assumed (the script never handles passwords). + +## Execution phases + +1. **Preflight (local).** Verify: git worktree present, `docker buildx` available, ssh connectivity to the host (`BatchMode=yes`), remote architecture is x86_64 (abort otherwise), domain resolves to the host's IP (warn, don't abort — DNS may still be propagating). +2. **Provision (remote, idempotent).** Install Docker (official convenience script) and Caddy (apt repo) if missing; create 1 GB swap if total RAM < 2 GB and no swap exists; create `/data` and `/srv/editable`. +3. **Configure (remote, idempotent).** Write `/etc/caddy/Caddyfile` (reverse_proxy block for the domain) and reload Caddy if it changed. First run: prompt for `ADMIN_PASSWORD`, write `.env` with it plus `ORIGIN` and any local bucket credentials. +4. **Build & upload (local → remote).** Skipped with `--tag`. Build `editable:` for linux/amd64, stream via `ssh docker load`. Upload `docker-compose.yml`. +5. **Activate (remote).** `IMAGE_TAG= docker compose up -d --remove-orphans` in `/srv/editable` (tag passed via the `.deploy_env` file, not shell interpolation). +6. **Verify.** Poll `127.0.0.1:3000` via ssh until healthy or timeout; on success also curl `https://` from the local machine (warn-only — TLS issuance or DNS may lag). Print logs and fail otherwise. +7. **Cleanup & report.** Prune `editable:*` images beyond the newest 3. Print the deployed tag, the site URL, and (first run) the local `.env` block for the data commands. + +## Repository changes + +1. `docker-compose.yml` — switch the service to `image: 'editable:${IMAGE_TAG:-local}'`; keep everything else (ports, env_file, bind mount) as is +2. `scripts/vps-deploy.sh` — the script per this spec (`set -euo pipefail`; remote steps as small quoted heredoc scripts, no unvalidated interpolation into remote shells) +3. `package.json` — `"vps:deploy": "./scripts/vps-deploy.sh"` and `"vps:env": "./scripts/vps-deploy.sh env"` +4. `README.md` — rewrite Deploy to a VPS around the script; keep the manual compose flow as a short "doing it by hand" note +5. `.env.example` — `DEPLOY_HOST` as the only needed key, explicit keys as hand-managed overrides + +## Implementation steps + +1. Compose switch to `image:` + verify the local `docker compose up --build` path still works +2. Script skeleton: argument parsing, preflight, ssh helpers +3. Provision + configure phases against a throwaway droplet +4. Build/upload/activate/verify phases; end-to-end first deploy +5. Second-run path (update deploy), `--tag` rollback, image pruning +6. README rewrite + `package.json` script + +Each step is independently verifiable; 3–5 need a real amd64 VPS to test against. + +Status: implemented (compose switch, script, `vps:deploy` / `vps:env` / `vps:status` npm scripts, `data.sh` auto-discovery, README, `.env.example`). Verified against a real DigitalOcean droplet: first deploy (provisioning, TLS via Caddy, password prompt), update deploy (cached build, container replacement, health check, report), and the short-form `env` show and `status` commands with server-side site discovery. Real-world hardening that came out of that testing: ssh retries on transient connection drops (fresh droplets get hammered by brute-force bots, and sshd's MaxStartups randomly sheds new connections) and ssh connection multiplexing so each run plays that lottery only once. Also verified: `--tag` deploys (used to ship a compose-only fix without rebuilding) and `data.sh` against a script-managed server with only `DEPLOY_HOST` set. That testing surfaced and fixed a pre-existing bug: `docker-compose.yml` never set `DATA_DIR=/data` (fly.toml does for Fly), so compose-run containers wrote content to the ephemeral `/app/data` and every redeploy silently wiped it — now fixed in the compose file. Still untested: `env set`/`unset` and the disaster-recovery path (fresh droplet restoring from a backup bucket). + +## Open questions + +- Should the script optionally harden the box (ufw allowing 22/80/443, unattended-upgrades)? Writebook's installer does some of this. Leaning yes for ufw, no for anything beyond — but deferred until after v1 works end to end. +- Multi-site per server was considered and decided against: the tool stays committed to one site per server, hardened and simplified, rather than growing toward a mini-PaaS. Consequence: nothing is scoped by a site name — fixed paths (`/data`, `/srv/editable`), fixed container name (`editable`), no name derivation. Anyone wanting many sites runs many cheap servers (or uses Fly.io). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..433eae6c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +# Run Editable on any docker host — a DigitalOcean droplet, a Hetzner or +# Nodion VPS, a home server. See README → Deploy to a VPS. Fly.io deployments +# don't use this file (fly deploy builds the same Dockerfile directly). +# +# Two ways to use it: +# - scripts/vps-deploy.sh uploads this file and starts a locally built image +# (IMAGE_TAG and CONTAINER_NAME come from the .deploy_env file it writes) +# - by hand on the server: `docker compose up -d --build` builds from source +# and runs under the default name `editable` +# +# The app listens on 127.0.0.1:3000 — put a reverse proxy with TLS in front +# (Caddy makes that two lines, see the README). +services: + editable: + build: . + image: 'editable:${IMAGE_TAG:-local}' + container_name: '${CONTAINER_NAME:-editable}' + restart: unless-stopped + ports: + - '127.0.0.1:3000:3000' + env_file: .env + environment: + # Point the app at the bind mount — without this it defaults to ./data + # inside the container and content silently dies with each deploy + # (fly.toml sets the same for Fly deployments). + DATA_DIR: /data + BODY_SIZE_LIMIT: '${BODY_SIZE_LIMIT:-30000000}' + volumes: + # Host data location: ./data next to this file by default (local and + # hand-managed runs); vps-deploy.sh pins it to /data via .deploy_env so + # the host path matches the container path and Fly's volume mount. + - '${HOST_DATA_DIR:-./data}:/data' diff --git a/package.json b/package.json index 0ca48e2c..e8455759 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "data:cloud-snapshots": "./scripts/data.sh cloud-snapshots", "data:verify": "./scripts/data.sh verify", "data:reset": "./scripts/data.sh reset", + "vps:deploy": "./scripts/vps-deploy.sh", + "vps:env": "./scripts/vps-deploy.sh env", + "vps:status": "./scripts/vps-deploy.sh status", "litestream:install": "./scripts/install-litestream.sh" }, "devDependencies": { diff --git a/scripts/check-assets.js b/scripts/check-assets.js index 4be28f18..2abe42e4 100644 --- a/scripts/check-assets.js +++ b/scripts/check-assets.js @@ -4,17 +4,19 @@ // and after a push (in-container, against the live volume). Standalone: no // $lib imports, so it runs against the built image too. // -// Usage: node --disable-warning=ExperimentalWarning check-assets.js +// Usage: node --disable-warning=ExperimentalWarning check-assets.js [--list-entries] // Exit codes: 0 = all present, 1 = missing references, 2 = bad usage. import { DatabaseSync } from 'node:sqlite'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, lstatSync } from 'node:fs'; +import { extname, join } from 'node:path'; -const [, , db_path, assets_dir] = process.argv; +const args = process.argv.slice(2); +const list_entries = args[0] === '--list-entries'; +const [db_path, assets_dir] = list_entries ? args.slice(1) : args; if (!db_path || !assets_dir) { - console.error('usage: check-assets.js '); + console.error('usage: check-assets.js [--list-entries] '); process.exit(2); } @@ -48,5 +50,26 @@ if (missing.length > 0) { process.exit(1); } +if (list_entries) { + // `referenced` comes exclusively from the database above. Responsive + // variants are not stored as separate database references: the application + // derives their URLs and storage directory from the referenced original's + // hash. Include that directory when it exists, following the same storage + // convention as asset_storage.js. + const entries = new Set(referenced); + for (const asset_id of referenced) { + const ext = extname(asset_id); + if (!ext) continue; + const asset_stem = asset_id.slice(0, -ext.length); + try { + if (lstatSync(join(assets_dir, asset_stem)).isDirectory()) entries.add(asset_stem); + } catch { + // This referenced original has no responsive variants on disk. + } + } + for (const entry of [...entries].sort()) console.log(entry); + process.exit(0); +} + // Parsed by remote-db.sh (summary) and data.sh (verify) — keep the shape. console.log(`OK: all ${plural(referenced.size, 'referenced asset')} present`); diff --git a/scripts/data.sh b/scripts/data.sh index 51ae57a8..536a6c98 100755 --- a/scripts/data.sh +++ b/scripts/data.sh @@ -1,7 +1,13 @@ #!/usr/bin/env bash # # Sync, back up, and recover the editable-website data folder (SQLite DB + -# content-addressed assets) between your local machine and a Fly.io deployment. +# content-addressed assets) between your local machine and a deployment. +# +# Deployments are reached through a driver: 'fly' (Fly.io, the default) or +# 'ssh' (any VPS reachable over plain ssh, running the app via docker compose +# or bare node). All provider-specific behavior funnels through five +# primitives — ensure_running, remote, sftp_get, sftp_put, restart_app — +# everything else is identical across providers. # # Safety model # - The database is copied only as a `VACUUM INTO` snapshot — never a raw file @@ -27,14 +33,28 @@ # ./scripts/data.sh reset # reset local database to fresh demo content # ./scripts/data.sh help # print the command reference # -# The target app is read from fly.toml (app = '...'), same as the fly CLI. -# Override with -a or the FLY_APP environment variable (-a wins). +# Fly driver: the target app is read from fly.toml (app = '...'), same as the +# fly CLI. Override with -a or the FLY_APP environment variable (-a wins). +# +# Ssh driver: configured via environment variables or .env (environment wins). +# For a server managed by scripts/vps-deploy.sh, DEPLOY_HOST is the only key +# needed — the rest is discovered from the server. The explicit keys are for +# other setups (bare node, hand-managed compose) and always override: +# DEPLOY_HOST user@host to ssh into (setting this selects the driver) +# RESTART_CMD how to restart the app, e.g. 'docker restart editable' +# REMOTE_EXEC command prefix to enter the app context, e.g. +# 'docker exec editable' (empty = bare node on the host) +# REMOTE_APP_DIR app dir inside the exec context (default /app) +# REMOTE_DATA_DIR data dir inside the exec context (default /data) +# HOST_DATA_DIR data dir as visible to scp on the host — set when it's a +# docker bind mount (default: REMOTE_DATA_DIR) +# DEPLOY_NAME label for backups and messages (default: the host) +# DEPLOY_DRIVER 'fly' or 'ssh' — set explicitly to override the inference # set -euo pipefail DATA_DIR_LOCAL="${DATA_DIR:-data}" BACKUP_DIR_LOCAL="data-backups" -REMOTE_DATA="/data" KEEP_BACKUPS="${KEEP_BACKUPS:-10}" APP="${FLY_APP:-}" ASSET_RE='^[a-f0-9]{64}(\.|$)' @@ -61,6 +81,68 @@ if [ -z "$APP" ] && [ -f "$SCRIPT_DIR/../fly.toml" ]; then APP="$(sed -n -E "s/^app[[:space:]]*=[[:space:]]*[\"']?([A-Za-z0-9-]+).*/\1/p" "$SCRIPT_DIR/../fly.toml" | sed -n '1p')" fi +# ---- deploy driver ----------------------------------------------------------- +# Ssh-driver configuration may live in .env — the environment wins over .env. +if [ -f "$SCRIPT_DIR/../.env" ]; then + while IFS='=' read -r key value; do + case "$key" in + DEPLOY_DRIVER | DEPLOY_HOST | DEPLOY_NAME | REMOTE_APP_DIR | REMOTE_DATA_DIR | HOST_DATA_DIR | REMOTE_EXEC | RESTART_CMD) + # Strip one layer of surrounding quotes. + value="${value%\"}"; value="${value#\"}" + value="${value%\'}"; value="${value#\'}" + eval "current=\${$key:-}" + [ -n "$current" ] || eval "$key=\$value" + ;; + esac + done <"$SCRIPT_DIR/../.env" +fi + +DRIVER="${DEPLOY_DRIVER:-}" +if [ -z "$DRIVER" ]; then + if [ -n "${DEPLOY_HOST:-}" ]; then DRIVER=ssh; else DRIVER=fly; fi +fi +[ "$DRIVER" = "fly" ] || [ "$DRIVER" = "ssh" ] || { + echo "Error: Unknown DEPLOY_DRIVER '$DRIVER' (expected 'fly' or 'ssh')" >&2 + exit 2 +} + +DEPLOY_HOST="${DEPLOY_HOST:-}" +REMOTE_APP_DIR="${REMOTE_APP_DIR:-/app}" +REMOTE_DATA="${REMOTE_DATA_DIR:-/data}" +HOST_DATA_DIR="${HOST_DATA_DIR:-}" +REMOTE_EXEC="${REMOTE_EXEC:-}" + +# A vps-deploy.sh-managed server needs only DEPLOY_HOST — the marker file at +# /srv/editable/.deploy_env names the container and host data path, the way +# fly.toml resolves the rest for the fly driver. Explicit values always win, +# and without the marker (bare node, hand-managed compose) nothing changes +# and the explicit keys below stay required. +SSH_CONFIG_DISCOVERED=false +discover_ssh_config() { + [ "$SSH_CONFIG_DISCOVERED" = false ] || return + SSH_CONFIG_DISCOVERED=true + + if [ "$DRIVER" = "ssh" ] && [ -n "$DEPLOY_HOST" ] && + [ -z "$REMOTE_EXEC" ] && [ -z "${RESTART_CMD:-}" ] && [ -z "$HOST_DATA_DIR" ]; then + local marker container + marker="$(ssh "$DEPLOY_HOST" 'cat /srv/editable/.deploy_env 2>/dev/null' 2>/dev/null || true)" + if [ -n "$marker" ]; then + container="$(printf '%s\n' "$marker" | sed -n 's/^CONTAINER_NAME=//p')" + if printf '%s' "$container" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*$'; then + REMOTE_EXEC="docker exec $container" + RESTART_CMD="docker restart $container" + HOST_DATA_DIR="$(printf '%s\n' "$marker" | sed -n 's/^HOST_DATA_DIR=//p')" + fi + fi + fi + + HOST_DATA_DIR="${HOST_DATA_DIR:-$REMOTE_DATA}" +} + +if [ "$DRIVER" = "ssh" ]; then + APP="${DEPLOY_NAME:-${DEPLOY_HOST#*@}}" +fi + TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT @@ -71,7 +153,12 @@ info() { echo "→ $*"; } plural() { [ "$1" -eq 1 ] && echo "$1 $2" || echo "$1 ${3:-${2}s}"; } need_app() { - [ -n "$APP" ] || die "No app configured — set app = 'my-site' in fly.toml, or pass -a " + discover_ssh_config + if [ "$DRIVER" = "ssh" ]; then + [ -n "${DEPLOY_HOST:-}" ] || die "No deploy host configured — set DEPLOY_HOST='user@host' in .env or the environment" + else + [ -n "$APP" ] || die "No app configured — set app = 'my-site' in fly.toml, or pass -a " + fi } confirm() { @@ -83,6 +170,12 @@ confirm() { MID="" ensure_running() { + if [ "$DRIVER" = "ssh" ]; then + # A VPS is always on — just confirm it's reachable before starting work. + ssh -o ConnectTimeout=10 "$DEPLOY_HOST" true >/dev/null 2>&1 || + die "Cannot reach '$DEPLOY_HOST' over ssh" + return + fi # sed (not head) reads all input, avoiding a SIGPIPE that pipefail would trip. # flyctl pads the -q output with whitespace, so strip it. MID="$(fly machine list -a "$APP" -q | sed -n '1p' | tr -d '[:space:]')" @@ -118,12 +211,53 @@ verify_remote() { printf '%s' "$out" | grep -q '^OK:' || die "Remote references missing assets — $ctx" } -# Run the in-container helper over SSH (pty-less, binary-safe). +# Run the in-app helper over SSH (pty-less, binary-safe). REMOTE_EXEC enters +# the app context on generic hosts (e.g. 'docker exec editable'); the env +# prefix runs inside that context, so DATA_DIR always names the in-app path. remote() { - fly ssh console -a "$APP" --machine "$MID" -C "sh /app/scripts/remote-db.sh $*" + if [ "$DRIVER" = "ssh" ]; then + # shellcheck disable=SC2029 + ssh "$DEPLOY_HOST" "${REMOTE_EXEC:+$REMOTE_EXEC }env DATA_DIR='$REMOTE_DATA' sh $REMOTE_APP_DIR/scripts/remote-db.sh $*" + else + fly ssh console -a "$APP" --machine "$MID" -C "sh $REMOTE_APP_DIR/scripts/remote-db.sh $*" + fi +} + +# Translate an in-app data path to the path scp sees on the host — they only +# differ when the data dir is a docker bind mount (HOST_DATA_DIR). +host_path() { + case "$1" in + "$REMOTE_DATA"/*) printf '%s%s' "$HOST_DATA_DIR" "${1#"$REMOTE_DATA"}" ;; + *) printf '%s' "$1" ;; + esac +} + +sftp_get() { + if [ "$DRIVER" = "ssh" ]; then + scp -q "$DEPLOY_HOST:$(host_path "$1")" "$2" + else + fly ssh sftp get -a "$APP" --machine "$MID" "$1" "$2" + fi +} +sftp_put() { + if [ "$DRIVER" = "ssh" ]; then + scp -q "$1" "$DEPLOY_HOST:$(host_path "$2")" + else + fly ssh sftp put -a "$APP" --machine "$MID" "$1" "$2" + fi +} + +# Restart the app so the boot-time promote swaps in a staged database. +restart_app() { + if [ "$DRIVER" = "ssh" ]; then + [ -n "${RESTART_CMD:-}" ] || + die "Set RESTART_CMD (e.g. 'docker restart editable' or 'systemctl restart editable') so the staged database can be swapped in" + # shellcheck disable=SC2029 + ssh "$DEPLOY_HOST" "$RESTART_CMD" + else + fly machine restart "$MID" -a "$APP" + fi } -sftp_get() { fly ssh sftp get -a "$APP" --machine "$MID" "$1" "$2"; } -sftp_put() { fly ssh sftp put -a "$APP" --machine "$MID" "$1" "$2"; } # Backup names double as identifiers, so they must be unique even for two # operations in the same second (a random suffix, not just seconds). They @@ -169,7 +303,11 @@ cmd_push() { remote prune-backups "$KEEP_BACKUPS" info "Syncing assets (additive)…" - list_local_assets >"$TMP/local_assets" + # Push only the snapshot's working set (referenced originals and their + # variant directories). Unreferenced local history must not be copied to a + # deployment merely because it is still inside the local grace period. + node --disable-warning=ExperimentalWarning "$SCRIPT_DIR/check-assets.js" \ + --list-entries "$TMP/push.db" "$DATA_DIR_LOCAL/assets" | sort >"$TMP/local_assets" # The listing must provably succeed — a failed connection must not read # as an empty asset list (pull would silently miss media). list-assets # prints a '#' header, so success is never empty even with zero assets. @@ -179,9 +317,9 @@ cmd_push() { comm -23 "$TMP/local_assets" "$TMP/remote_assets" >"$TMP/to_push" || true if [ -s "$TMP/to_push" ]; then info " $(plural "$(wc -l <"$TMP/to_push" | tr -d ' ')" 'new asset entry' 'new asset entries')" - # COPYFILE_DISABLE: keep macOS tar from embedding xattr headers that - # GNU tar on the server warns about. - COPYFILE_DISABLE=1 tar -czf "$TMP/assets.tgz" -C "$DATA_DIR_LOCAL/assets" --exclude '.DS_Store' -T "$TMP/to_push" + # Keep macOS tar from embedding xattr headers that GNU tar on the server + # warns about. COPYFILE_DISABLE also prevents AppleDouble sidecars. + COPYFILE_DISABLE=1 tar --no-xattrs -czf "$TMP/assets.tgz" -C "$DATA_DIR_LOCAL/assets" --exclude '.DS_Store' -T "$TMP/to_push" sftp_put "$TMP/assets.tgz" "$REMOTE_DATA/incoming/assets.tgz" remote extract-assets else @@ -193,14 +331,18 @@ cmd_push() { remote promote-stage info "Restarting to swap in the new database…" - fly machine restart "$MID" -a "$APP" + restart_app info "Verifying…" verify_remote "restore with: npm run data:restore $ts" echo echo "✓ Pushed to '$APP'. Undo with:" - echo " npm run data:restore $ts -- -a $APP" + if [ "$DRIVER" = "fly" ]; then + echo " npm run data:restore $ts -- -a $APP" + else + echo " npm run data:restore $ts" + fi } # ---- pull: remote -> local ------------------------------------------------- @@ -307,7 +449,7 @@ cmd_restore() { fi info "Restarting to swap in the restored database…" - fly machine restart "$MID" -a "$APP" + restart_app info "Verifying…" local out @@ -369,7 +511,7 @@ cmd_restore_cloud() { remote promote-stage info "Restarting to swap in the restored database…" - fly machine restart "$MID" -a "$APP" + restart_app info "Verifying…" verify_remote "roll back with: npm run data:restore $ts" @@ -524,7 +666,8 @@ Data commands (via npm run; positional arguments work directly, flags need a -- npm run data:reset [-- --yes] reset local database to fresh demo content (assets stay) npm run litestream:install one-time local setup for the cloud commands -The target app comes from fly.toml; override with: -- -a +The target comes from fly.toml (Fly.io, override with: -- -a ) or from +DEPLOY_HOST in .env (any VPS over plain ssh — see README → Deploy to a VPS). Snapshot names () look like my-site-20260712T143535Z-3f2a (file extension optional) — list them with data:backups. Timestamps () are RFC3339 UTC, e.g. 2026-07-12T14:35:35Z — list them with data:cloud-snapshots. See README → Backup, sync & recovery for details. diff --git a/scripts/vps-deploy.sh b/scripts/vps-deploy.sh new file mode 100755 index 00000000..ab9a9fe4 --- /dev/null +++ b/scripts/vps-deploy.sh @@ -0,0 +1,463 @@ +#!/usr/bin/env bash +# +# Deploy an Editable site to a VPS — see VPS_DEPLOY_SPEC.md for the design. +# +# One command takes a fresh Ubuntu server (amd64, ssh key access) to a running, +# TLS-terminated site; the same command run again ships a code update. The +# image is built locally and streamed over ssh — the server never needs git +# access, npm, or the memory to run a build. The only state that matters on +# the server is the bind-mounted data directory /data (the same path Fly.io +# mounts); the container is disposable and replaced on every deploy. +# +# Usage +# ./scripts/vps-deploy.sh first deploy (or explicit target) +# ./scripts/vps-deploy.sh deploy to DEPLOY_HOST +# ./scripts/vps-deploy.sh status show the running tag and rollback candidates +# ./scripts/vps-deploy.sh env show the server's env (secrets masked) +# ./scripts/vps-deploy.sh env set KEY=VALUE … set env vars and restart the app +# ./scripts/vps-deploy.sh env set KEY prompt for the value (hidden input) +# ./scripts/vps-deploy.sh env unset KEY … remove env vars and restart the app +# +# The short forms read DEPLOY_HOST from your local .env — the deployment +# block printed after the first deploy, added by hand so it's transparent +# where the target comes from — and discover the site on the server. The +# explicit form works before that block exists and always overrides it: +# ./scripts/vps-deploy.sh root@203.0.113.10 my-site.example.com [env …] +# +# Options +# --tag deploy an already-uploaded image tag (rollback) instead of +# building — the last 3 tags are kept on the server +# --yes skip confirmation prompts (except the first-run password) +# +# Every deploy runs the same idempotent phases — preflight, provision, +# configure, build & upload, activate, verify, cleanup. The first run does +# everything; later runs fall through to the deploy phases in seconds. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." + +die() { echo "Error: $*" >&2; exit 1; } +info() { echo "→ $*"; } +warn() { echo "Warning: $*" >&2; } + +confirm() { + [ "$YES" = true ] && return 0 + local reply + printf '%s [y/N] ' "$1" + read -r reply [env …] + TARGET="${POSITIONAL[0]}" + DOMAIN="${POSITIONAL[1]:-}" + [ -n "$DOMAIN" ] || die "the explicit form needs a domain: vps-deploy.sh $TARGET " + ACTION="${POSITIONAL[2]:-deploy}" + ENV_ARGS=("${POSITIONAL[@]:3}") + case "$ACTION" in + deploy | env | status) ;; + *) die "Unknown command '$ACTION' (see --help)" ;; + esac + ;; + "" | env | status) + # Short form: the target comes from DEPLOY_HOST (environment wins over + # .env), the site and its domain are discovered on the server. + ACTION="${POSITIONAL[0]:-deploy}" + ENV_ARGS=("${POSITIONAL[@]:1}") + TARGET="${DEPLOY_HOST:-$(local_env_val DEPLOY_HOST)}" + [ -n "$TARGET" ] || die "DEPLOY_HOST is not set — add the deployment block to .env (printed after the first deploy), or pass an explicit target: vps-deploy.sh user@host domain" + case "$TARGET" in + *@*) ;; + *) die "DEPLOY_HOST must be user@host, e.g. root@203.0.113.10" ;; + esac + DOMAIN="" # discovered on the server + ;; + *) die "Unknown command '${POSITIONAL[0]}' (see --help)" ;; +esac + +# Values interpolated into remote commands are validated to safe character +# sets first — everything else reaches the remote side via stdin only. +validate_domain() { + DOMAIN="$(echo "$DOMAIN" | tr '[:upper:]' '[:lower:]')" + echo "$DOMAIN" | grep -Eq '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' || + die "'$DOMAIN' does not look like a domain name" +} +[ -z "$DOMAIN" ] || validate_domain + +# One site per server, so nothing needs a per-site name: runtime files live +# in /srv/editable, the data in /data (the same path Fly.io mounts and the +# same path inside the container), and the container is plain 'editable' — +# identical to a hand-managed compose setup. +SRV="/srv/editable" +CONTAINER="editable" + +if [ -n "$TAG" ]; then + echo "$TAG" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*$' || die "'$TAG' is not a valid image tag" +fi + +REMOTE_USER="${TARGET%%@*}" +HOST_ADDR="${TARGET#*@}" + +SUDO="" +[ "$REMOTE_USER" = "root" ] || SUDO="sudo" + +TMP="$(mktemp -d)" +# Close the multiplexing master connection, then remove the temp dir. +trap 'ssh -o ControlPath="$TMP/ssh" -O exit "$TARGET" 2>/dev/null || true; rm -rf "$TMP"' EXIT + +# accept-new: trust a fresh server's host key on first contact (a brand-new +# droplet is never in known_hosts), but still fail hard if a known key changes. +# +# ControlMaster: multiplex every ssh call over one connection. A fresh VPS is +# hammered by ssh brute-force bots within minutes, and sshd (MaxStartups) +# randomly drops new incoming connections while bots fill the pending slots — +# with multiplexing only the first connection has to get through. +SSH_OPTS=( + -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new + -o ControlMaster=auto -o ControlPath="$TMP/ssh" -o ControlPersist=yes +) +rssh() { ssh "${SSH_OPTS[@]}" "$TARGET" "$@"; } + +# Like rssh, but retries transient connection failures (ssh exit code 255) — +# fresh droplets drop occasional connections while they finish booting. Only +# for commands that read nothing from stdin (a retry would find it drained). +rssh_retry() { + local attempt rc + for attempt in 1 2 3 4 5; do + rc=0 + rssh "$@" /dev/null || die "docker is not installed locally" + command -v git >/dev/null || die "git is not installed" + if [ -z "$TAG" ]; then + docker buildx version >/dev/null 2>&1 || die "docker buildx is not available" + git rev-parse --short HEAD >/dev/null 2>&1 || die "not inside a git checkout" + fi +fi + +info "Checking ssh connectivity to $TARGET" +rssh_retry true 2>/dev/null || die "cannot ssh into $TARGET (key-based access required)" + +# Short form: read the deployed site's domain from the server. +if [ -z "$DOMAIN" ]; then + rssh_retry "test -f $SRV/.deploy_env" 2>/dev/null || + die "no Editable deployment found on $TARGET — run the first deploy explicitly: vps-deploy.sh $TARGET " + DOMAIN="$(rssh_retry "$SUDO cat $SRV/.env" | sed -n 's|^ORIGIN="https://\([^"]*\)".*|\1|p' | sed -n '1p')" + [ -n "$DOMAIN" ] || die "could not read the site's domain (ORIGIN) from $SRV/.env" + validate_domain + info "Site: $DOMAIN ($TARGET)" +fi + +# ---- status command ---------------------------------------------------------- +# What's running and what can be rolled back to — the tags live on the server, +# the commit messages behind them live in this checkout's git history. + +if [ "$ACTION" = "status" ]; then + { + printf 'CONTAINER=%q\n' "$CONTAINER" + cat <<'REMOTE' +docker ps --filter "name=^$CONTAINER$" --format '{{.Image}}|{{.Status}}' +echo --- +docker images editable --format '{{.Tag}}|{{.CreatedAt}}' +REMOTE + } | rssh "$SUDO bash -s" >"$TMP/status" + + RUNNING_IMG="$(awk -F'|' '/^---$/ { exit } { print $1; exit }' "$TMP/status")" + RUNNING_STATE="$(awk -F'|' '/^---$/ { exit } { print $2; exit }' "$TMP/status")" + if [ -n "$RUNNING_IMG" ]; then + info "Running: $RUNNING_IMG ($RUNNING_STATE) — https://$DOMAIN" + else + warn "no running container named $CONTAINER" + fi + + echo + echo "Images on the server (newest first):" + awk 'found { print } /^---$/ { found = 1 }' "$TMP/status" | + while IFS='|' read -r tag created; do + base_tag="${tag%-dirty}" + desc="$(git show -s --format=%s "$base_tag" 2>/dev/null || echo '(not a commit in this checkout)')" + [ "$tag" = "$base_tag" ] || desc="$desc + uncommitted changes" + marker="" + [ "editable:$tag" = "$RUNNING_IMG" ] && marker=" ← running" + printf ' %-16s %.16s %s%s\n' "$tag" "$created" "$desc" "$marker" + done + echo + echo "Roll back with: npm run vps:deploy -- --tag " + exit 0 +fi + +# ---- env command ------------------------------------------------------------- +# Explicit env var management, fly-secrets style: show / set / unset. Changes +# take effect by recreating the container (env_file is baked in at creation). + +restart_with_env() { + rssh "$SUDO tee $SRV/.env >/dev/null && $SUDO chmod 600 $SRV/.env" <"$TMP/env" + info "Restarting the app with the new environment" + rssh_retry "cd $SRV && $SUDO docker compose --env-file .env --env-file .deploy_env up -d" + rssh_retry 'for i in $(seq 30); do curl -fs -o /dev/null http://127.0.0.1:3000 && exit 0; sleep 1; done; exit 1' || + die "the app did not come back healthy — inspect with: ssh $TARGET '$SUDO docker logs $CONTAINER'" +} + +if [ "$ACTION" = "env" ]; then + rssh "test -f $SRV/.env && test -f $SRV/.deploy_env" 2>/dev/null || + die "no deployment at $SRV yet — deploy first" + rssh "$SUDO cat $SRV/.env" >"$TMP/env" + SUB="${ENV_ARGS[0]:-show}" + case "$SUB" in + show) + sed -E 's/^(ADMIN_PASSWORD|AWS_SECRET_ACCESS_KEY)=.*/\1="…"/' "$TMP/env" + ;; + set) + [ ${#ENV_ARGS[@]} -ge 2 ] || die "env set needs at least one KEY=VALUE (or KEY to be prompted)" + for pair in "${ENV_ARGS[@]:1}"; do + key="${pair%%=*}" + echo "$key" | grep -Eq '^[A-Z][A-Z0-9_]*$' || die "'$key' is not a valid env var name" + if [ "$pair" = "$key" ]; then + printf 'Value for %s (input hidden): ' "$key" + read -rs value "$TMP/env.new" || true + printf '%s="%s"\n' "$key" "$value" >>"$TMP/env.new" + mv "$TMP/env.new" "$TMP/env" + info "Set $key" + done + restart_with_env + ;; + unset) + [ ${#ENV_ARGS[@]} -ge 2 ] || die "env unset needs at least one KEY" + for key in "${ENV_ARGS[@]:1}"; do + grep -q "^$key=" "$TMP/env" || die "$key is not set on the server" + grep -v "^$key=" "$TMP/env" >"$TMP/env.new" || true + mv "$TMP/env.new" "$TMP/env" + info "Unset $key" + done + restart_with_env + ;; + *) die "Unknown env command '$SUB' (expected: show, set, unset)" ;; + esac + exit 0 +fi + +REMOTE_ARCH="$(rssh_retry uname -m)" +[ "$REMOTE_ARCH" = "x86_64" ] || + die "server is $REMOTE_ARCH — only amd64 servers are supported (the image pins the x86_64 Litestream build)" + +# DNS sanity check — warn only, propagation may still be in progress. +if echo "$HOST_ADDR" | grep -Eq '^[0-9.]+$' && command -v dig >/dev/null; then + RESOLVED="$(dig +short A "$DOMAIN" | sed -n '1p')" + if [ -z "$RESOLVED" ]; then + warn "$DOMAIN does not resolve yet — TLS will fail until the A record points at $HOST_ADDR" + elif [ "$RESOLVED" != "$HOST_ADDR" ]; then + warn "$DOMAIN resolves to $RESOLVED, not $HOST_ADDR — TLS will fail until DNS is fixed" + fi +fi + +FIRST_RUN=false +rssh_retry "test -f $SRV/.env" 2>/dev/null || FIRST_RUN=true + +if [ "$FIRST_RUN" = true ]; then + confirm "Set up $TARGET as a new Editable server for https://$DOMAIN?" +fi + +# ---- phase 2: provision (remote, idempotent) --------------------------------- + +info "Provisioning server (no-op when already set up)" +{ + printf 'APP_USER=%q\n' "$REMOTE_USER" + cat <<'REMOTE' +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +command -v curl >/dev/null || { apt-get update -qq; apt-get install -y -qq curl ca-certificates; } +command -v docker >/dev/null || { echo "→ Installing Docker"; curl -fsSL https://get.docker.com | sh; } +command -v caddy >/dev/null || { + echo "→ Installing Caddy" + apt-get update -qq + apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https gnupg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | + gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + >/etc/apt/sources.list.d/caddy-stable.list + apt-get update -qq + apt-get install -y -qq caddy +} + +# 1 GB swap on small machines, so the running app never gets OOM-killed. +total_kb=$(awk '/MemTotal/ {print $2}' /proc/meminfo) +if [ "$total_kb" -lt 2000000 ] && [ "$(swapon --noheadings | wc -l)" -eq 0 ]; then + echo "→ Creating 1 GB swapfile (server has <2 GB RAM)" + fallocate -l 1G /swapfile + chmod 600 /swapfile + mkswap /swapfile >/dev/null + swapon /swapfile + grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >>/etc/fstab +fi + +mkdir -p /data /srv/editable +[ "$APP_USER" = "root" ] || chown "$APP_USER" /data /srv/editable +REMOTE +} | rssh "$SUDO bash -s" + +# ---- phase 3: configure (remote, idempotent) --------------------------------- + +info "Configuring Caddy for $DOMAIN" +{ + printf 'DOMAIN=%q\n' "$DOMAIN" + cat <<'REMOTE' +set -euo pipefail +mkdir -p /etc/caddy/sites +site_file="/etc/caddy/sites/editable.caddy" +desired="$DOMAIN { + reverse_proxy 127.0.0.1:3000 +}" +changed="" +if [ ! -f "$site_file" ] || [ "$(cat "$site_file")" != "$desired" ]; then + printf '%s\n' "$desired" >"$site_file" + changed=1 +fi +grep -q 'import sites/\*\.caddy' /etc/caddy/Caddyfile || { + printf '\nimport sites/*.caddy\n' >>/etc/caddy/Caddyfile + changed=1 +} +[ -z "$changed" ] || systemctl reload caddy +REMOTE +} | rssh "$SUDO bash -s" + +BUCKET_KEYS="BUCKET_NAME AWS_ENDPOINT_URL_S3 AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY" + +# Append KEY="value" for each bucket key with a local value to the file in $1. +append_bucket_creds() { + local key value + for key in $BUCKET_KEYS; do + value="$(local_env_val "$key")" + [ -n "$value" ] && printf '%s="%s"\n' "$key" "$value" >>"$1" + done + return 0 +} + +if [ "$FIRST_RUN" = true ]; then + info "Creating the server's .env" + GENERATED="$(openssl rand -base64 18 2>/dev/null || head -c 18 /dev/urandom | base64)" + printf 'Admin password for %s [enter for generated: %s]: ' "$DOMAIN" "$GENERATED" + read -r ADMIN_PW "$TMP/env" + append_bucket_creds "$TMP/env" + rssh "$SUDO tee $SRV/.env >/dev/null && $SUDO chmod 600 $SRV/.env" <"$TMP/env" +fi + +# ---- phase 4: build & upload ------------------------------------------------- + +if [ -z "$TAG" ]; then + TAG="$(git rev-parse --short HEAD)" + # Uncommitted or untracked changes end up in the image — make the tag say + # so. Deploying dirty again reuses the tag (dirty states aren't versioned; + # commits are the rollback anchors). + if [ -n "$(git status --porcelain)" ]; then + TAG="$TAG-dirty" + warn "uncommitted changes — tagging the image $TAG" + fi + info "Building editable:$TAG for linux/amd64" + docker buildx build --platform linux/amd64 -t "editable:$TAG" --load . + info "Streaming image to the server (this can take a few minutes)" + docker save "editable:$TAG" | gzip | rssh "gunzip | $SUDO docker load" +else + rssh "$SUDO docker image inspect editable:$TAG >/dev/null 2>&1" || + die "image editable:$TAG is not on the server (only built tags can be rolled back to)" + info "Deploying existing image editable:$TAG" +fi + +rssh "$SUDO tee $SRV/docker-compose.yml >/dev/null" /dev/null" +rssh_retry "cd $SRV && $SUDO docker compose --env-file .env --env-file .deploy_env up -d --remove-orphans" + +# ---- phase 6: verify --------------------------------------------------------- + +info "Waiting for the app to respond" +if ! rssh_retry 'for i in $(seq 30); do curl -fs -o /dev/null http://127.0.0.1:3000 && exit 0; sleep 1; done; exit 1'; then + echo "--- container logs -----------------------------------------------------" >&2 + rssh_retry "$SUDO docker logs --tail 50 $CONTAINER" >&2 || true + die "the app did not become healthy — the failing container is left running for inspection" +fi + +if ! curl -fsS -o /dev/null --max-time 15 "https://$DOMAIN" 2>/dev/null; then + warn "https://$DOMAIN is not reachable from here yet (DNS propagation or TLS issuance may still be in progress)" +fi + +# ---- phase 7: cleanup & report ----------------------------------------------- + +# Keep the newest 3 editable images for --tag rollbacks (rmi of an in-use +# image fails harmlessly, e.g. right after rolling back to an older tag). +rssh_retry "$SUDO docker images editable --format '{{.Repository}}:{{.Tag}}' | tail -n +4 | xargs -r $SUDO docker rmi >/dev/null 2>&1 || true" + +echo +info "Deployed editable:$TAG to https://$DOMAIN" +if [ "$FIRST_RUN" = true ]; then + cat <