Skip to content

What the Setup Script Does

j5guy edited this page Jul 25, 2026 · 4 revisions

What the setup script does

This is a line-by-line account of everything install.sh (the curl/wget-able entrypoint; setup.sh is an alias for it, for an existing checkout) and the wizard it launches (scripts/setup-wizard.js) install, configure, write to disk, or send over the network — for anyone who wants to know exactly what they're running before they run it, or is troubleshooting a step that failed partway through. For the usage walkthrough (what the form asks and what to pick), see the Installation Guide. For what a later ./update.sh run does differently, see Updating.

Nothing here runs with elevated privileges until explicitly noted, and every sudo prompt is called out below — the script never silently escalates.

Testing scope. The Linux path (install.sh/setup.sh) has only actually been run on Rocky Linux 9 and Ubuntu 24.04/26 — Ubuntu tested as a VM and on bare metal, not as an LXC container (see Ubuntu LXC is not recommended in the Installation Guide) — other distros follow the same logic but haven't been as thoroughly exercised. This script covers a lot of environment variation (package managers, service managers, Docker installers) automatically, and won't be perfect on every system. If something breaks, please report it — GitHub issues — with your OS/version and what you ran, so it can get fixed for the next person. In the meantime, Manual / non-Docker deployment or the manual docker compose commands are always a fallback if the script itself doesn't work on your system.

All of the above is x86_64 only — ARM (e.g. Raspberry Pi) is untested, but should mostly work: Node and Docker Engine both officially support Debian/Ubuntu on arm64/armhf. The guided setup always builds the image locally — this project doesn't publish a pre-built one itself (see CI/CD and Releases) — so there's no architecture-mismatch pitfall to worry about here. Building locally compiles bcrypt from source as a fallback if no prebuilt binary exists for your platform (see the Dockerfile step below) — slower, but should still succeed.

The phases

Running curl ... | bash (or ./install.sh/./setup.sh from an existing checkout) does the following in order:

  1. Clone the repo into a scratch directory (skipped when run from an existing checkout — that checkout is used in place instead) — git clone --depth 1, after installing git/curl first if either is missing.
  2. Install Node.js, if it isn't already on PATH.
  3. Run npm install, if node_modules/ doesn't exist yet — installs this project's dependencies (Express, Mongoose, bcrypt, etc.) from the public npm registry into node_modules/ inside the repo. Nothing outside the repo is touched by this step.
  4. Launch the setup wizard (node scripts/setup-wizard.js) — a small local web server that collects your .env values, then brings the app up. This is where the interesting stuff happens; see below — including the "Installation footprint" choice (only offered when a scratch clone from step 0 is actually in play) that decides whether the checkout stays, or gets trimmed down and deleted once the stack is up (see Updating for the equivalent split on a later update).

Re-running install.sh/setup.sh always reopens the wizard, prefilled with whatever's already in .env/.deploy-state.json. To redeploy/restart with the existing settings unchanged instead — e.g. after a code update — use ./update.sh (see Updating), not the wizard.

Step 1: installing Node.js

Only runs if node isn't found on PATH. Exact commands, per platform:

  • Linux (setup.sh): NodeSource's install script, via whichever package manager is present — curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - then sudo apt-get install -y nodejs (Debian/Ubuntu), or the dnf equivalent (Fedora/RHEL/Rocky). If neither apt-get nor dnf is found, the script stops and asks you to install Node manually. If curl itself isn't present (some minimal images, recent Ubuntu included, don't ship it) it's installed first via the same package manager — the Docker install path (below) does the same check before its own curl use.

Step 2: installing npm dependencies

Only runs if node_modules/ doesn't already exist (so a re-run doesn't redo it every time). Plain npm install from the project root, pulling this project's dependencies (Express, Mongoose, bcrypt, etc. — see package.json) from the public npm registry into node_modules/. Nothing outside the repo is touched — no system-wide packages, no config written anywhere else.

Step 3: the setup wizard

scripts/setup-wizard.js starts a plain Express server, bound to your machine (not the public internet) on the first free port from 5599 up, and opens it in your default browser automatically. If you're on a headless server with no GUI, it also prints a LAN URL (http://<your-lan-ip>:<port>/) you can open from another computer — anyone else on that network can reach this page, unauthenticated, until you submit the form, so keep that window short on a shared network.

When rendering the form, it also checks whether this CPU can actually run current MongoDB versions (reading /proc/cpuinfo for the atomics feature flag — see MongoDB on ARM) and shows a plain read-only notice next to the MongoDB choice saying which image it'll use and why — there's no field to fill in. If Internal MongoDB is submitted, MONGO_IMAGE in .env is set to match (mongo:4.4.18 if the CPU check failed, otherwise left blank so docker-compose.mongo.yml's own default, mongo:7, applies).

The wizard also checks whether nginx is already installed on this host (nginx --version, and systemctl is-active nginx for whether it's actually running) and, if so, scans /etc/nginx/sites-enabled/ and /etc/nginx/conf.d/ for every distinct ssl_certificate/ ssl_certificate_key pair already referenced there — offered in the form as picks instead of generating a new certificate. This is shown in an Existing nginx section: an INFO note if nginx is detected (with a checkbox to add a site for it, described further below), or a PASS note if it isn't ("the bundled Docker nginx container will be deployed instead" in Docker mode; a note pointing at the local-mode reverse-proxy warning otherwise). The TLS certificate choice is shown right after this, since which options actually apply depends on it.

While the form is open, the wizard also does several small live-check requests as you type, all entirely local:

  • "generate" buttons next to sessionSecret/PRELOGIN_SECRET call back to the wizard itself, which returns crypto.randomBytes(64).toString('hex') — generated locally, never sent anywhere.
  • SSL_CERT_FILE/SSL_KEY_FILE live check — as you type a path, the wizard calls fs.statSync() on its own host (the machine running the wizard, which is also the machine Docker will read the bind-mounted cert from) to show a ✓/✗ next to the field.
  • WEB_FQDN DNS check — as you type, the wizard calls dns.lookup() on its own host and shows a green "DNS resolves for this address" note if it resolves, or a yellow note if it doesn't, saying setup can still proceed but it's recommended to have the name in DNS (or a hosts file entry) before relying on it day-to-day. This check is informational only and never blocks saving — but the field itself is required and can't be left blank (it starts empty, with a suggested-format note next to it, rather than prefilled with a placeholder domain).
  • NGINX_HTTPS_PORT/HOST_NGINX_IP_PORT live port check — as you type either port field (the bundled Docker nginx's HTTPS port, or an existing host nginx's LAN-IP port — see below), the wizard attempts to actually bind that port on its own host and reports whether it's free, suggesting the next free port after it if not. HOST_NGINX_IP_PORT additionally can't match the app's own fixed port (5560) — that number is always already taken on this host in "Existing nginx" mode.
  • "Test connection" button next to MongoDB — attempts an authenticated connection with whatever's currently typed into the form (host/port/user/pass/db name), without saving anything yet. Required to pass before submitting whenever MongoDB isn't the bundled internal container (i.e. local mode, or Docker + External MongoDB) — the internal container doesn't exist yet at this point in the flow, so there's nothing reachable to test there.

Every field marked "Required" (sessionSecret, PRELOGIN_SECRET, ADMIN_EMAIL, WEB_FQDN, and SSL_CERT_FILE/SSL_KEY_FILE whenever "I'll provide my own certificate files" applies) swaps that red note for a green "PASS: filled in." one live, the moment it actually has something in it — no server round-trip, just checking the field isn't blank.

Submitting the form (POST /save) does the following, in order:

  1. (Only when a certificate is actually needed — Docker mode always, or local mode with "Existing nginx" enabled — and "Generate one" is picked) Generates a local TLS certificate. Entirely local via the openssl CLI — nothing is sent over the network. Creates (or reuses, if already present) a Certificate Authority at certs/ca.key / certs/ca.pem, then issues a leaf certificate at certs/cert.key / certs/cert.pem for WEB_FQDN, with SAN entries covering WEB_FQDN and this machine's LAN IP address(es) plus 127.0.0.1. The CA's private key never leaves this step — it stays in certs/. This certificate isn't trusted by anything yet — see Import the certificate first — you (and anyone else who needs to reach the site) still need to install the downloaded CA certificate into each device's OS/browser trust store before visiting the site there. If instead one of the detected "Use the certificate detected at..." options was picked, that cert/key path is used as-is — nothing is generated.
  2. Writes .env (file mode 0600, project root) with everything you filled in.
  3. Writes .deploy-state.json — non-secret bookkeeping (deploy mode, service name/user, Mongo mode, cert mode, image source, whether "Existing nginx" was enabled) so a later re-run of setup.sh without reopening the wizard still knows how to redeploy.
  4. Brings the app up, one of two ways depending on what you picked:

If you picked Docker

  • Installs Docker, only if it's missing, and only now that you've actually chosen Docker — never preemptively. Exact commands:
    • Linux, Rocky specifically: sudo dnf install dnf-plugins-core, then registers Docker's RHEL repo directly (download.docker.com/linux/rhel/docker-ce.repo — Docker's own Rocky repo is missing packages, so this sidesteps that) and installs docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-ce-rootless-extras docker-buildx-plugin via sudo dnf.
    • Linux, everything else: Docker's official convenience script — curl -fsSL https://get.docker.com | sudo sh.
    • Linux only, after either path: sudo systemctl enable --now docker (starts Docker and sets it to start on boot) and sudo usermod -aG docker <your username> (lets you run docker without sudo — needs a fresh login/newgrp docker to take effect).
  • Runs docker compose up -d --build — always builds the image from this checkout (see Why build locally instead of pulling an image? in the README) — using whichever compose files apply: docker-compose.yml always, plus either docker-compose.nginx.yml (the bundled nginx sidecar — the default) or docker-compose.host- nginx.yml (exposes the app container's port directly to the host instead, skipping the bundled nginx entirely) if "Existing nginx" was enabled, plus docker-compose.mongo.yml if you picked internal MongoDB. This is what actually builds and starts the containers: app (this application), nginx (TLS termination — reads the cert from step 1 or your own files — only when not using an existing host nginx instead), and mongo (only if internal MongoDB was picked).
  • (If "Existing nginx" was enabled) Removes any previous version of this site, then writes and enables a new one. Detects the sites-available/sites-enabled (Debian/Ubuntu) or conf.d (RHEL/Rocky) layout, deletes the old site file (and its sites-enabled symlink, if any) so nothing stale can linger, then writes three server blocks: :80 redirects to HTTPS for WEB_FQDN only, :443 serves WEB_FQDN over TLS, and a third listener on HOST_NGINX_IP_PORT (the port you picked, live-checked above) serves this host's LAN IP address(es) — all three proxy to the app's fixed port (5560). Runs nginx -t, then sudo systemctl reload nginx. The :80/:443 FQDN blocks deliberately don't also answer to the bare IP, since this host's nginx may front other sites too — claiming the IP on the same port they use could steal it from them; the dedicated HOST_NGINX_IP_PORT sidesteps that entirely. This is why the bundled nginx sidecar above is skipped in this case — running both would fight over host port 80/443.
  • Inside the app image build itself (see Dockerfile), npm install --omit=dev and the SCSS build (sasspublic/css/main.css) both happen — you don't need Node installed on the Docker host for this path beyond what the wizard itself needed to run. That npm install temporarily installs build-essential/python3 first and purges them again afterward, in the same image layer — a fallback so bcrypt's native module compiles from source on platforms without a prebuilt binary for it (e.g. possibly ARM — see the testing-scope note above) instead of failing the build outright.

If you picked "Local Node.js process"

No Docker involved at all. Runs npm install and npm run build-css (compiles public/scss/main.scsspublic/css/main.css), then registers node server.js as a background service so it survives reboots and restarts on crash:

Writes a systemd unit to /etc/systemd/system/<service-name>.service (via sudo tee — you'll be prompted for a sudo password), then sudo systemctl daemon-reload, enable, and restart. Runs as whatever user you specified in the form (defaults to whoever ran the wizard).

This mode does not set up MongoDB, TLS, or a reverse proxy for you — point mongoHost in .env at an existing MongoDB server, and put your own TLS-terminating reverse proxy in front of the app yourself. See Local Node.js process, without the wizard for the manual equivalent of everything above.

Finally: prints where the app is reachable

Once Docker (or the local service) is actually up, the last thing printed is the URL(s) the app is reachable at — WEB_FQDN plus this machine's LAN IP address(es), scheme and port included — highlighted (bold cyan) in the terminal so it stands out from the rest of the setup output, via scripts/lib/network.js's highlight() (skipped automatically when stdout isn't a real terminal, or NO_COLOR is set). In Docker mode (without "Existing nginx") that's NGINX_HTTPS_PORT, the bundled nginx's own port (e.g. https://fondwaypoints.example.com:5560/ — 5560 is just the default; you may have picked a different free port in the form). In local mode (without "Existing nginx") it's the app's own fixed port, 5560 — whatever reverse proxy you're running yourself is assumed to be terminating TLS there. 127.0.0.1 is deliberately never listed — nothing outside this machine can reach the site there, so printing it would be misleading. ./update.sh (see Updating) prints the same highlighted URL(s) again once it finishes.

If "Existing nginx" was enabled instead, this changes: the app's own fixed port is plain HTTP at that point (the host nginx is doing TLS, not that port directly), which isn't a secure context, so the app's client-side WebCrypto wouldn't actually work there — it's never printed. Instead you get https://WEB_FQDN/ (through the host nginx, once its site is confirmed live) plus https://<lan-ip>:HOST_NGINX_IP_PORT/ for each LAN IP address — the dedicated port that same host nginx site also listens on for direct IP access (see above).

Everything written to disk, in one place

Path What Committed to git?
node_modules/ npm dependencies No (.gitignore)
.env Your secrets/config, mode 0600 No (.gitignore)
.deploy-state.json Non-secret deploy bookkeeping, so re-runs know what to do No (.gitignore)
certs/ca.key, certs/ca.pem Local CA, only if you picked "Generate one" No (.gitignore)
certs/cert.key, certs/cert.pem, certs/cert.csr, certs/cert.ext Leaf cert issued by the above No (.gitignore)
public/css/main.css Compiled from public/scss/ No (.gitignore)
logs/ App logs (Winston); also service stdout/stderr No (.gitignore)
/etc/systemd/system/<name>.service (local mode) systemd unit Outside the repo entirely
/etc/nginx/sites-available/<name>.conf (+ a sites-enabled symlink on Debian/Ubuntu) or /etc/nginx/conf.d/<name>.conf (RHEL/Rocky) Host nginx site, only if "Existing nginx" was enabled Outside the repo entirely

Everything that touches the network

  • Package managers/installers, only for whatever's actually missing: NodeSource/apt/dnf (Node on Linux), Docker's get.docker.com script or RHEL repo (Docker on Linux).
  • The npm registry, for npm install (this project's dependencies) — both directly (local/Docker build) and inside the CI-published image build.
  • Debian's package mirrors, inside the Docker image build only, to install (and then remove again) build-essential/python3 — the bcrypt native-module fallback mentioned above.
  • Docker Hub, to pull the node:20-bookworm-slim base image the first time you build locally (or whenever it's changed) — cached after that. The guided setup never contacts any other registry — see Why build locally instead of pulling an image? in the README.
  • Whatever you configure: the MongoDB host and SMTP relay you enter into the form — the app talks to those once it's running, same as any deployment.
  • Nothing else. The wizard's own web server is local/LAN-only and never phones home; there's no telemetry or analytics anywhere in this project.

Privilege escalation, called out explicitly

sudo is used only for:

  • Installing Node.js or Docker system packages (Steps 1 and 4).
  • sudo systemctl enable --now docker and sudo usermod -aG docker <user> (Linux Docker install).
  • Writing the systemd unit and enabling/starting the service (local deploy mode).
  • (If "Existing nginx" was enabled) Writing the host nginx site config (sudo tee), symlinking it into sites-enabled on Debian/Ubuntu layouts (sudo ln -sf), testing it (sudo nginx -t), and reloading nginx (sudo systemctl reload nginx).

If sudo isn't available at all (and you're not already root), the relevant step fails with a message telling you so rather than silently skipping it.

Re-running later

Re-running install.sh/setup.sh after .env already exists always reopens the wizard, prefilled with your current values (including .deploy-state.json's record of which mode you picked last time) — use this to change a setting. Local-mode service installs use restart (not a fresh enable), and the Docker CA is reused rather than regenerated, so re-running this won't invalidate CA trust you've already installed on other devices.

To instead just redeploy/restart with what's already there — most commonly, to pick up a newer release — use ./update.sh (see Updating) rather than reopening the wizard.