Prepare the hosts
You need to prepare a build and test host to check out the source code, run the test suite and build the installation artifact. You need to prepare a production host to receive the deployment and run the system. You can deploy to the build host, but this is typically only done for testing the deployment.
Supported versions
| Tool | Build | Production | Elsewhere | Supported |
|---|---|---|---|---|
| Ubuntu LTS | ✓ | ✓ | 24.04 LTS, and nothing else | |
| Node | ✓ | ✓ | 24.x — the same major version on each, so what was tested is what runs | |
| npm | ✓ | whatever ships with Node 24 | ||
| git | ✓ | 2.43+, with the repository's tags | ||
| Caddy | ✓ | 2.7+, from Caddy's own apt repository | ||
| systemd | ✓ | 255+ | ||
| ufw | ✓ | 0.36.x | ||
| Docker | ✓ | docker.io with the docker-compose-v2 plugin |
||
| MySQL | ✓ | the database server | 8.4.x — on the build host as a container, for the tests | |
| MySQL client | ✓ | mysql and mysqldump, to copy a facility's database before a schema change |
||
| a way to run SQL | the machine you run it from | a mysql client, or a database tool such as DataGrip |
Every version ZFM requires is stated here and nowhere else; the checks on this page report what a machine has, for you to compare against this table.
The build and test host
On the build and test host you run the test suite and turn a checkout into an installation artifact.
Check the operating system
lsb_release -ds
Node and npm
node --version
npm --version
Node and npm compile the server and export the web client. If Node is missing, or is not the major
version the table above names, install it — from a version manager such as nvm, or with the
nodesource instructions under the production host. npm is automatically installed with Node.
git
git --version
The build reads the repository and its tags: the server and the web client each bake their version
in from git describe, so a checkout whose tags were never fetched produces an installation artifact that cannot
say what it is. sudo apt install -y git if it is missing.
The source
Clone the repository, which is private, so this needs an account that can read it:
git clone https://github.com/tmoens/zebrafish-facility-manager
Everything from here on runs inside that checkout, and every path this guide gives is relative to its root.
A MySQL 8 for the tests
The test suite creates a throwaway schema for each of its workers, runs the migration chain into it
and drops it at the end, so this host needs a MySQL 8 that will let an account create and drop
databases. The checkout brings one, in docker-compose.yml:
docker compose up -d # from the root of the checkout
docker compose ps # the mysql service is up, published on 127.0.0.1:3308
If Docker is missing, install Ubuntu's own packages and give yourself access to the daemon:
sudo apt install -y docker.io docker-compose-v2
sudo usermod -aG docker "$USER" # log out and back in for this to take effect
If you would rather use a MySQL 8 you already run, point the suite at it instead, with an account that may create and drop databases:
export TEST_DB_HOST=… TEST_DB_PORT=… TEST_DB_USER=… TEST_DB_PASSWORD=…
The production host
Check the operating system
lsb_release -ds
systemctl --version
Node
Install from the nodesource repository, not the distro's: Ubuntu 24.04 ships Node 18, which the
server does not run on. apt then patches Node as part of the machine's ordinary updates.
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
node --version # installed at /usr/bin/node
The host runs one Node, at /usr/bin/node. A systemd unit has no login shell, so each facility's
unit names that path explicitly.
npm is not needed here. The installation artifact arrives with its dependencies already in it, so nothing is
resolved or built on this host; the Node package installs npm anyway and it goes unused. The
commands this guide gives for a production host run the compiled code directly, as
node dist/scripts/…, rather than through an npm script, because those scripts go through
ts-node — a development dependency that an installed artifact does not carry.
Caddy — install from Caddy's own apt repository
Install from Caddy's own apt repository, not apt install caddy against stock Ubuntu/Debian
sources. The distro package is frozen at release time and runs years behind (Ubuntu 24.04 ships
2.6.2, from 2022); our Caddyfiles use {args[N]} placeholders and other ≥2.7 syntax, and on 2.6
those silently resolve to nothing — the proxy dials port 0 and every request 502s. Same server,
same config, wrong repo = broken facility.
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy
caddy version
Every machine that serves ZFM through Caddy needs this repository, production hosts and development machines alike.
Give the host a directory for its facility files. Each facility gets a small configuration file
of its own, written when the facility is set up, and Caddy reads them because the host's own
Caddyfile imports the directory they sit in. Do this once per host, and look before you act — on a
host that already serves a facility it is done:
sudo ls -ld /etc/caddy/conf.d
If the directory is there, go on to the next section. Otherwise create it and add the import:
sudo install -d -o root -g root -m 755 /etc/caddy/conf.d
echo 'import /etc/caddy/conf.d/*.caddy' | sudo tee -a /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
The host keeps its own Caddyfile and a facility never edits it. Caddy requires the global options
block, which configures the server itself rather than any one site, to come first and refuses it
from an imported file — so the one file that belongs to the machine stays separate from the ones
that come and go with facilities.
Firewall — read the rules on the host
A production host is normally a public droplet: every listener it has is on the internet unless something
stops it. Only Caddy's 80/443 and ssh should be reachable; the per-facility zf-server ports
and the database are for localhost and for whoever administers them, not the world.
Ask ufw itself what is loaded. The check most people run answers a different question.
sudo ufw status verbose # the rules actually in force
systemctl is-active ufw is not that check. ufw.service is a oneshot that runs ufw-init,
which reads ENABLED from /etc/ufw/ufw.conf; when that says no it loads nothing and exits
0, and systemd reports active (exited) — its normal healthy state for a completed oneshot. A
host with no firewall at all reports active. Read ENABLED and ufw status verbose, nothing
else.
If the host has no firewall yet, establish one. Add the allow rules before enable, or you
lose your ssh session:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow <ssh-port>/tcp
sudo ufw allow 80,443/tcp comment 'caddy — LE http-01 needs 80'
sudo ufw enable
Port 80 stays open alongside 443: Let's Encrypt's HTTP-01 challenge uses it, so closing it breaks certificate renewal ~60 days later, long after the change looks successful.
Two things to confirm while you're there:
IPV6=yesin/etc/default/ufw. A droplet with a global v6 address and v4-only rules is open on v6, and there is no NAT in front of it.- Bind addresses, if anything on the host ever runs in Docker. Docker writes its own rules
into the
DOCKERchain, which netfilter traverses before UFW's chains — a published container port is reachable whatever UFW policy says. Publish such ports as127.0.0.1:<port>:<port>.
If the host is reachable only over a private network
Allow 80 and 443 on that network's interface instead of opening them to the internet. A machine serving a test deployment over a Tailscale tailnet is a good example.
Read the ufw status verbose output from above first. A rule reading Anywhere on tailscale0 opens
every port on the tailnet, 80 and 443 included, and it is the rule your ssh session is arriving on,
so there is nothing to add. If the interface has no such rule, add one:
sudo ufw allow in on tailscale0 to any port 80,443 proto tcp
Serve the facility over http, with its name in the hosts file of whatever runs the browser. No certificate authority can issue a certificate for a host like this: the name is not public and port 80 is unreachable from the internet, so Let's Encrypt's HTTP-01 challenge never succeeds. Serve a facility gives that form of the address.
If you will run the provisioning SQL from this host
Creating a facility's database and its account takes a few SQL statements, run as the db admin account. Install a client that can reach MySQL 8.4, and have the database service let this machine connect — on a managed service, by naming it in the allow-list.
mysql --version
A MariaDB client is fine, because it speaks the same protocol. MySQL 8.4 offers only
caching_sha2_password, though, which needs the connection encrypted or the server's public key
fetched first: a client that refuses to log in needs --ssl-ca, or apt install mysql-client.
Confirm the host is ready
lsb_release -ds
node --version
caddy version
sudo ufw status verbose
The machine is now an ordinary Ubuntu host with the software ZFM runs on. Nothing on it is ZFM yet: the program, the account it runs as and its settings all arrive in the next two pages.