Skip to content

Install by hand

See also: Production install · Installation hub · Installer reference · The web server site · Several instances on one host · Troubleshooting

This page installs a Production instance by hand, one command at a time. It is the human version of the installer's Review screen: every section is one host step of the Production route, in the order the installer runs them, and every command is the one that step runs. Files the Review shows only by path and digest are printed here in full. A host built from this page has the same layout, the same units, the same site file and the same backups as a host the installer built, so the two can live side by side.

Use it when you want to see and type every change yourself, when you adapt a host the installer would refuse, or to finish a migrated v6 host. For a new server the installer is still the shorter road: it measures the host first, refuses every clash before it changes anything, and can resume or undo itself. A host built by hand has none of that: you are the resume and the undo.

Before you start

You need the same things as the installer (Production › Before you start):

  • a supported system with systemd: Ubuntu 26.04 LTS, Debian 13, or RHEL, Rocky Linux or AlmaLinux 10. Only the newest release of each is supported;
  • root;
  • an instance name (<i>): a lowercase letter, then lowercase letters, digits or _, at most 28 characters;
  • the public address, with DNS pointing at this server, and ports 80 and 443 reachable;
  • for your institution's certificate, its fullchain and key files.

The layout

The layout is defined by role, not by fixed paths. Two places matter:

  • The instance directory (the install folder, <dir>) can be any path, and it is owned by the service user. It holds the release tree (<dir>/dedalo), the private directory (<dir>/private) and the backups (<dir>/backups). An in-app code update renames the release tree inside this directory and moves the old one to <dir>/backups/code, so the service user must be able to write here. Its parent can stay root-owned, even 0555: nothing is ever created or renamed there.
  • What root-started units run — the pinned Bun runtime and the backup, watchdog, rollback, alert and media-rule scripts — lives in root-owned directories outside every instance directory: /opt/dedalo/bun/<pin>/ and /opt/dedalo/instances/<i>/bin/. Root never runs a file the service user can replace.

The two worked examples on this page:

The installer's default An existing hosting account
instance name I prado my_domain
service user U ded_prado (created) my_domain (exists; used as it is)
service group G ded_prado (created with the user) ded_my_domain (created; a group of its own)
install folder DIR /home/ded_prado /home/my_domain.org/dedalo
its parent /home (root's) /home/my_domain.org (the account's)
the release tree /home/ded_prado/dedalo /home/my_domain.org/dedalo/dedalo
MEDIA_PATH /srv/dedalo/prado/dedalo/media /srv/dedalo/my_domain/dedalo/media
socket /run/dedalo-prado/dedalo_ts.sock /run/dedalo-my_domain/dedalo_ts.sock
database and role dedalo_prado, role ded_prado dedalo_my_domain, role my_domain
units dedalo-ts@prado and its siblings dedalo-ts@my_domain and its siblings

Only the install folder, the service user and group, and the backup directory are yours to choose. The other paths are fixed by the shared units and the policy module, so they follow the instance name:

  • /opt/dedalo/instances/<i>/bin/: the site-builder backup unit names it;
  • /opt/dedalo/bun/<pin>/bun: the SELinux module labels exactly this path;
  • /run/dedalo-<i>/: the server unit's RuntimeDirectory=dedalo-%i creates it at every start.

The media root, MEDIA_PATH, is /srv/dedalo/<i>/dedalo/media on this page. When the media lives on a volume of its own, mount that volume there.

The account rules are the installer's: The install folder and the service account. In short, use a regular account that is not root and is in no privileged group. Use one account, one group and one folder per instance. A group of its own is recommended, because the web server joins it.

The shell and the variables

Run every step in one root shell, with the default umask, so that each file is created 0644 and each directory 0755 unless a command says otherwise:

sudo -i
umask 022

Then set the variables every later command uses. The values are the default example; the hosting-account example is in the comments:

I=prado                      # the instance name                   (my_domain)
U=ded_prado                  # the service user                    (my_domain)
G=ded_prado                  # the service group                   (ded_my_domain)
DIR=/home/ded_prado          # the install folder                  (/home/my_domain.org/dedalo)
BACKUP_DIR=$DIR/backups      # the backup directory
NAME=dedalo.example.org      # the public address
EMAIL=it@example.org         # Let's Encrypt only: the expiry notices
MEDIA=/srv/dedalo/$I/dedalo/media
SOCK=/run/dedalo-$I/dedalo_ts.sock
BIN=/opt/dedalo/instances/$I/bin
RU="/usr/sbin/runuser -u $U -g $G --"    # a command run as the service user

Then the values of your system and web server. Set one block of each pair:

# Ubuntu 26.04, Debian 13
PGBIN=/usr/lib/postgresql/18/bin PGSVC=postgresql.service CERTBOT_TIMER=certbot.timer
#   nginx
WEB=nginx  WEBSVC=nginx   WEBUSER=www-data WEBLOG=/var/log/nginx   SITE=/etc/nginx/conf.d/dedalo-$I.conf
#   Apache
WEB=apache WEBSVC=apache2 WEBUSER=www-data WEBLOG=/var/log/apache2 SITE=/etc/apache2/sites-available/dedalo-$I.conf

# RHEL, Rocky Linux, AlmaLinux 10
PGBIN=/usr/pgsql-18/bin PGSVC=postgresql-18.service CERTBOT_TIMER=certbot-renew.timer
#   nginx
WEB=nginx  WEBSVC=nginx   WEBUSER=nginx    WEBLOG=/var/log/nginx   SITE=/etc/nginx/conf.d/dedalo-$I.conf
#   Apache
WEB=apache WEBSVC=httpd   WEBUSER=apache   WEBLOG=/var/log/httpd   SITE=/etc/httpd/conf.d/dedalo-$I.conf

# Every system: does SELinux apply here (the RHEL family, Enforcing or Permissive)?
SELINUX_MODULE=false
getenforce 2>/dev/null | grep -qiE '^(enforcing|permissive)$' && SELINUX_MODULE=true

# Every system: does this instance write nginx's host-wide media map? host_web_site sets it.
MAP_OWNER=false

PIN, the Bun version, is set in host_runtime from the release itself.

Files. The installer writes its files from templates, and so does this page: each file below is printed with @@NAME@@ placeholders, and the render function fills them in from the variables. Define it now, in this same shell: it reads the variables each time it runs, so PIN, set later in host_runtime, is filled in too. The unit loop in host_units stops when render is not defined, rather than write empty files:

render() {
  name_re=$(printf '%s' "$NAME" | sed 's/[.]/\\\\&/g')
  sed -e "s|@@INSTANCE@@|$I|g" -e "s|@@USER@@|$U|g" -e "s|@@GROUP@@|$G|g" \
      -e "s|@@DIR@@|$DIR|g" -e "s|@@BACKUP_DIR@@|$BACKUP_DIR|g" -e "s|@@PIN@@|$PIN|g" \
      -e "s|@@PG_BIN@@|$PGBIN|g" -e "s|@@PG_SERVICE@@|$PGSVC|g" \
      -e "s|@@SERVER_NAME_RE@@|$name_re|g" \
      -e "s|@@SERVER_NAME@@|$NAME|g" -e "s|@@LOG_DIR@@|$WEBLOG|g" \
      -e "s|@@SELINUX_MODULE@@|$SELINUX_MODULE|g" -e "s|@@NGINX_MAP_OWNER@@|$MAP_OWNER|g" \
      -e "s|@@WEB_SERVICE@@|$WEBSVC|g" "$@"
}

@@SERVER_NAME_RE@@ is the address as a regular expression, its dots escaped (dedalo\.example\.org), for the Apache sites' name guard, which matches the Host header against it. The installer escapes it the same way.

render <file> renders a file, and render <<'EOF' … EOF renders the text between the markers. Keep the quotes around the first EOF, so the shell leaves the $ in the files alone. The installer indents its files with tabs; this page shows them as spaces. The difference means nothing to systemd, nginx, Apache or JSON.

Every unit drop-in and site file starts with the stamp # dedalo-install <i> manual; the nginx media map include and the host-registry record carry none. The installer recognises the stamp: when it later installs another instance on this host, it counts this one when it checks that no two instances share a user, a group or a folder.

host_preflight — check the host

Installer: Production › host_preflight.

The installer measures the host and refuses, before any change, everything a later step would trip on. By hand, check the same list:

( . /etc/os-release; echo "$ID $VERSION_ID" ) # ubuntu 26.04 · debian 13 · rhel|rocky|almalinux 10.x
test -d /run/systemd/system && echo systemd    # systemd is running
nginx -v                                       # nginx already installed only: 1.25.1 or newer (`http2 on;`)
getent passwd "$U"; getent group "$G"          # absent, or the account you mean to use
ls -ld /srv/dedalo/$I "$DIR/private" "$BACKUP_DIR" /run/dedalo-$I 2>&1   # none may exist
ls /etc/systemd/system/ | grep "@$I\."         # no unit or drop-in of this instance
ls "$SITE" 2>&1                                # no site file for this instance
ls /var/lib/dedalo-install/instances/ 2>&1     # the other instances on this host
/usr/sbin/runuser -u postgres -- $PGBIN/psql -X -A -t -h /var/run/postgresql -p 5432 -d postgres -c \
  "SELECT 'role ' || rolname FROM pg_roles WHERE rolname = '$U'
   UNION ALL SELECT 'database ' || datname FROM pg_database WHERE datname = 'dedalo_$I'"
                                               # PostgreSQL already installed only: prints nothing

The first line reads /etc/os-release in a subshell, ( … ), on purpose: that file sets NAME (and ID, VERSION, …), and sourced in this root shell it would overwrite the page's own $NAME, the public address every later step renders.

On a fresh host, nginx: command not found and runuser: user postgres does not exist only mean that nginx and PostgreSQL are not installed yet. That is fine: host_packages installs them.

Also check:

  • The role and the database are free. No PostgreSQL role named like the service user and no database dedalo_<i> exist yet. The installer refuses either one before it changes anything: an existing role may carry a password, SUPERUSER or CREATEDB, and the engine would then run as it. A hosting account often has a role of its own name already. Stop and choose another service user, or remove what is left of an earlier install.
  • One web server. Nothing but your web server listens on ports 80 and 443 (ss -ltnp 'sport = :80 or sport = :443' names the program), and, when your web server is not installed yet, the other one is not installed either: the installer refuses a run that would install one web server beside the other, and any program other than your web server holding the ports. With both already installed, it goes ahead as long as only yours holds them.
  • The address is free. No other site on this host serves $NAME on ports 80 and 443 (nginx -T; Apache: apache2ctl -S on Debian and Ubuntu, httpd -S on the RHEL family, whose apachectl does not accept -S).
  • One PostgreSQL major. No cluster other than PostgreSQL 18 listens on port 5432. On Ubuntu and Debian, check with pg_lsclusters.
  • The folder. The install folder is absent, or empty, or an existing folder of the service user's. It is not reached through a symbolic link, not writable by group or others, and no web site on this host serves it.
  • One of everything per instance. No other instance uses the service user, the group or the folder. Read each /var/lib/dedalo-install/instances/*.json file and each User= line under /etc/systemd/system/dedalo-ts@*.service.d/.
  • The firewall lets ports 80 and 443 in. The installer never changes the firewall; open them yourself:

    firewall-cmd --get-default-zone; firewall-cmd --list-services        # firewalld (RHEL family): http and https listed?
    firewall-cmd --permanent --add-service=http --add-service=https && firewall-cmd --reload
    ufw status                                                           # ufw (Ubuntu): 80 and 443 allowed?
    ufw allow 80,443/tcp
    
  • Your institution's certificate, if you use one, names the address, is valid now, and pairs with its key (openssl x509 -in <fullchain> -noout -subject -enddate), and this host trusts its chain. The installer's last check runs this host's curl, which verifies the chain as a browser does. Check with openssl verify -untrusted <fullchain> <fullchain>. When it fails, with unable to get local issuer certificate, or with self-signed certificate in certificate chain when the file carries your authority's own certificate too, install your authority's certificate: Debian and Ubuntu, copy it to /usr/local/share/ca-certificates/<name>.crt, then update-ca-certificates; RHEL family, copy it to /etc/pki/ca-trust/source/anchors/, then update-ca-trust.

host_packages — packages and repositories

Installer: Production › host_packages.

The installer installs only the packages that are missing, and adds a repository only when a package needs it. Packages and repositories are shared host state: nothing on this page ever removes them.

Ubuntu 26.04. PostgreSQL 18 ships with the release, so no repository is added:

DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get update
DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get install -y --no-install-recommends \
  git unzip gzip file ca-certificates curl rsync diffutils postgresql-18 postgresql-client-18 ffmpeg imagemagick poppler-utils ocrmypdf librsvg2-bin \
  nginx certbot                       # or: apache2 certbot   (certbot: Let's Encrypt only)
/usr/bin/systemctl enable --now postgresql.service nginx      # or: apache2

Debian 13. PostgreSQL 18 comes from the PostgreSQL project's repository (PGDG), which its own setup script adds:

DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get update
DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get install -y --no-install-recommends postgresql-common
/usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get update
DEBIAN_FRONTEND=noninteractive /usr/bin/apt-get install -y --no-install-recommends \
  git unzip gzip file ca-certificates curl rsync diffutils postgresql-18 postgresql-client-18 ffmpeg imagemagick poppler-utils ocrmypdf librsvg2-bin \
  nginx certbot                       # or: apache2 certbot   (certbot: Let's Encrypt only)
/usr/bin/systemctl enable --now postgresql.service nginx      # or: apache2

RHEL, Rocky Linux, AlmaLinux 10. EPEL brings ImageMagick and certbot, CodeReady Builder brings EPEL's dependencies, RPM Fusion free brings FFmpeg, and PGDG brings PostgreSQL 18:

/usr/bin/dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm
/usr/bin/crb enable
/usr/bin/dnf install -y https://mirrors.rpmfusion.org/free/el/rpmfusion-free-release-10.noarch.rpm
/usr/bin/dnf install -y "https://download.postgresql.org/pub/repos/yum/reporpms/EL-10-$(uname -m)/pgdg-redhat-repo-latest.noarch.rpm"
/usr/bin/dnf install -y \
  git unzip gzip file ca-certificates curl tar rsync diffutils postgresql18-server postgresql18 postgresql18-contrib ffmpeg ImageMagick poppler-utils librsvg2-tools \
  policycoreutils-python-utils \
  nginx certbot                       # or: httpd mod_ssl mod_http2 certbot   (certbot: Let's Encrypt only)
test -e /var/lib/pgsql/18/data/PG_VERSION || \
  PGSETUP_INITDB_OPTIONS='--auth-local=peer --auth-host=scram-sha-256' /usr/pgsql-18/bin/postgresql-18-setup initdb
/usr/bin/systemctl enable --now postgresql-18.service nginx   # or: httpd
/usr/bin/dnf install -y ocrmypdf                   # optional: a failure only leaves PDF OCR off

ocrmypdf is optional on the RHEL family. EPEL 10 has no build for aarch64 (No match for argument: ocrmypdf), and without it automatic OCR of PDFs stays off.

With Apache on Debian and Ubuntu, enable its modules now (the package enables none of them): a2enmod ssl headers http2 rewrite proxy proxy_http && systemctl restart apache2. On the RHEL family, the httpd, mod_ssl and mod_http2 packages load them all.

What the media tools are for, and why SVG goes through rsvg-convert and never ImageMagick, is in Production › host_packages.

Two things the installer leaves to you:

  • Operating system updates. The installer never runs apt-get upgrade or dnf upgrade. Keep the host updated the way you keep your other servers updated.
  • OCR languages. For OCR in a language other than English, install its Tesseract pack (tesseract-ocr-spa, tesseract-langpack-spa…).

Verify: command -v ffmpeg ffprobe qt-faststart pdftotext rsvg-convert finds every tool. With nginx, nginx -v prints 1.25.1 or newer; an older nginx rejects the site's http2 on;. If qt-faststart is somewhere else, set DEDALO_AV_FASTSTART_PATH in .env after host_engine. $PGBIN/psql --version prints 18, and systemctl is-active $PGSVC prints active.

host_identity — the service account and the directories

Installer: Production › host_identity.

The account. Create only what does not exist. An existing account or group is used as it is: never change it.

if [ "$G" = "$U" ]; then
  # the default: a new user with its own same-named group
  getent passwd "$U" || /usr/sbin/useradd --system --user-group --home-dir "$DIR" --no-create-home \
    --shell /usr/sbin/nologin "$U"
else
  # a group of its own (the hosting-account example): the group first, then the user if it is new
  getent group "$G" || /usr/sbin/groupadd --system "$G"
  getent passwd "$U" || /usr/sbin/useradd --system --gid "$G" --no-user-group --home-dir "$DIR" \
    --no-create-home --shell /usr/sbin/nologin "$U"
fi

The account gets no login shell and no skeleton files. Its home is the install folder, which is created below, never by --create-home.

The install folder. Who creates it depends on whose directory it goes into:

# under root-owned parents (the default: /home is root's) — root creates it, for the user
install -d -o "$U" -g "$G" -m 0750 "$DIR"

# under a parent the account owns (the hosting-account example: /home/my_domain.org) — the user creates it
$RU /usr/bin/install -d -m 0750 "$DIR"

Root never creates anything by path inside a tree another account controls: an account that runs processes could swap a path under it. An existing empty folder under root-owned parents can be handed over (chown "$U:$G" "$DIR"; chmod 0750 "$DIR"). An existing folder of the user's own is used as it is.

The instance's directories. Inside the folder and inside its web root, the user creates them. The shared and root-run ones belong to root:

$RU /usr/bin/install -d -m 0700 "$DIR/private"
$RU /usr/bin/install -d -m 0750 "$DIR/backups"
$RU /usr/bin/install -d -m 0750 "$BACKUP_DIR" "$BACKUP_DIR/state"   # BACKUP_DIR inside $DIR (the default)
install -d -o root -g root -m 0755 /srv/dedalo
install -d -o "$U" -g "$G" -m 0750 "/srv/dedalo/$I"
$RU /usr/bin/install -d -m 0750 "/srv/dedalo/$I/dedalo" "$MEDIA" "/srv/dedalo/$I/client"
install -d -o root -g root -m 0755 /opt/dedalo /opt/dedalo/bun /opt/dedalo/instances \
  "/opt/dedalo/instances/$I" "$BIN"
install -d -o root -g root -m 0755 /var/lib/dedalo-install /var/lib/dedalo-install/acme \
  /var/lib/dedalo-install/instances
install -d -o root -g root -m 0755 "/var/lib/dedalo-install/acme/$I"   # Let's Encrypt only

A shared directory that already exists (/srv/dedalo, /opt/dedalo, … on a host with other instances) is used as it is and never re-moded. When the backup directory lies outside the install folder, create it and its state with install -d -o "$U" -g "$G" -m 0750 if every existing parent is root's, otherwise as the user.

Verify: getent passwd "$U"; stat -c '%a %U:%G %n' "$DIR" "$DIR/private" "$MEDIA" prints 750, 700 and 750, owned by the service user.

host_runtime — Bun, the release and the root-run scripts

Installer: Production › host_runtime.

The release. Download the release archive (Get Dédalo) as root, into a root-owned hand-over directory that the service group may read, and check it against its published sha256 before anything reads it:

V=<version>                                        # the release, for example 7.1.0
REL=/var/lib/dedalo-install/release-$I
install -d -o root -g "$G" -m 0750 "$REL"
cd "$REL"
/usr/bin/curl -fsSL --proto =https --tlsv1.2 -o release.zip \
  "https://v7.master.dedalo.dev/dedalo/install/code/$V/$V.zip"
/usr/bin/curl -fsSL --proto =https --tlsv1.2 -o release.zip.sha256 \
  "https://v7.master.dedalo.dev/dedalo/install/code/$V/$V.zip.sha256"
printf '%s  release.zip\n' "$(cut -c1-64 release.zip.sha256)" | sha256sum -c -    # release.zip: OK
/usr/bin/unzip -q release.zip -d tree
SRC=$(dirname "$(dirname "$(ls "$REL"/tree/scripts/install.ts "$REL"/tree/*/scripts/install.ts 2>/dev/null)")")
PIN=$(cat "$SRC/.bun-version")

From a checkout instead of the archive (a version not published as an archive, for example), clone it into the same place and check out the release's tag. Run this in place of the curl and unzip lines, before setting SRC:

/usr/bin/git clone https://github.com/dedalia-org/dedalo.git "$REL/tree"
/usr/bin/git -C "$REL/tree" checkout "v$V"

The archive step below leaves .git out of the release, so an instance built from a checkout reports its version as <version>-dev, with no build date, on /health, the login page and the Maintenance panel: only a published archive carries the build stamp. It runs the same code. install_digest on /health is null on every new install, the installer's included: it names the archive an in-app code update installed, and none has run yet. The archive holds the tree either at its top level or in one top-level directory; SRC is wherever scripts/install.ts is. Keep $REL until host_selinux: the units and the policy module are copied from it, never from the instance's own tree.

A manual install is a tree, not a container image, so it is the one route that can follow a developer build: the nightly (<next>-dev) reaches a production or dev installation as a tree archive through the code update path, never as a docker image. A container installation runs tagged releases only (stable or beta tags), because docker images are immutable and the dev channel publishes none.

Bun, at the release's pin, root-owned. It comes only from a zip whose sha256 is the release's committed row in .bun-sha256, checked before it is unpacked:

case "$(uname -m)" in
  aarch64) ASSET=bun-linux-aarch64.zip ;;
  *) grep -qw avx2 /proc/cpuinfo && ASSET=bun-linux-x64.zip || ASSET=bun-linux-x64-baseline.zip ;;
esac
install -d -o root -g root -m 0700 "/var/lib/dedalo-install/bun/$PIN"
cd "/var/lib/dedalo-install/bun/$PIN"
test -s "$ASSET" || /usr/bin/curl -fsSL --proto =https --tlsv1.2 -o "$ASSET" \
  "https://github.com/oven-sh/bun/releases/download/bun-v$PIN/$ASSET"
grep " $ASSET\$" "$SRC/.bun-sha256" | sha256sum -c -          # bun-linux-….zip: OK — stop otherwise
/usr/bin/unzip -o -q "$ASSET" -d extract
/usr/bin/install -D -m 0755 -o root -g root "extract/${ASSET%.zip}/bun" "/opt/dedalo/bun/$PIN/bun"
/usr/bin/rm -rf extract

On a host where another instance already runs this pin, /opt/dedalo/bun/$PIN/bun is already there: leave it. The installer compares it byte for byte with the verified one (cmp -s) and does not install it again, so a running instance's binary is never replaced.

Never bun upgrade on a production server

The engine is coupled to version-specific runtime behaviour, and a silent drift there is a data-corruption class. A new pin is installed next to the old one, as above, and the units are pointed at it.

The release in place. Root archives the tree; the service user extracts it and installs its locked dependencies:

/usr/bin/tar -C "$SRC" --exclude=./node_modules --exclude=./.git -cf "$REL/release.tar" .
$RU /usr/bin/install -d -m 0750 "$DIR/dedalo"
$RU /usr/bin/tar -xf "$REL/release.tar" -C "$DIR/dedalo" --no-same-owner --no-overwrite-dir
cd "$DIR/dedalo"
BUN_INSTALL_CACHE_DIR="$DIR/dedalo/.bun-install-cache" TMPDIR="$DIR/dedalo" \
  $RU /usr/bin/env HOME="$DIR" "/opt/dedalo/bun/$PIN/bun" install --frozen-lockfile --production --ignore-scripts
$RU /usr/bin/rm -rf "$DIR/dedalo/.bun-install-cache"

The cd matters: runuser keeps the directory you are standing in. --no-overwrite-dir keeps $DIR/dedalo at 0750: the archive's own ./ entry would otherwise give it the source's 0755. --frozen-lockfile refuses any dependency tree other than the tested one, and --production leaves out the test harness and the linters. The package cache lives inside the tree and is removed straight after. TMPDIR keeps Bun's temporary files inside the tree too: with /tmp on another filesystem, Bun leaves an empty /tmp/.<hash>-0.hm owned by the service user, which would outlive the account if the instance is ever removed. runuser resets HOME to the account's own home, so every Bun command on this page sets HOME to the install folder, as the installer does: nothing is left in the account's home.

The root-run scripts, copied root-owned, byte for byte, outside the code tree. A code update moves the whole tree aside, and the rollback has to work exactly when the tree is missing:

for script in dedalo-backup-step.sh dedalo-db-backup.sh dedalo-tree-backup.sh dedalo-site-builder-backup.sh dedalo-backup-alert.sh dedalo-ts-watchdog.sh dedalo-code-rollback.sh dedalo-media-rules.sh dedalo-tls-rotate.sh; do
  /usr/bin/install -m 0755 -o root -g root "$SRC/deploy/$script" "$BIN/$script"
done

Verify: /opt/dedalo/bun/$PIN/bun --version prints $PIN; stat -c '%U' /opt/dedalo/bun/$PIN/bun prints root; stat -c %a "$DIR/dedalo" prints 750; ls "$DIR/dedalo/node_modules" "$BIN".

host_database — the role and the database

Installer: Production › host_database.

The role is named like the service user and has no password: PostgreSQL's peer authentication maps the system user to the role of the same name over the local socket, so no database password exists at rest. The database dedalo_<i> belongs to it:

PSQL="/usr/sbin/runuser -u postgres -- $PGBIN/psql -X -q -A -t -v ON_ERROR_STOP=1 -h /var/run/postgresql -p 5432"
$PSQL -d postgres -c "SELECT 1 / count(*) FROM pg_roles WHERE rolname = '$U'" >/dev/null 2>&1 || \
  printf 'CREATE ROLE "%s" LOGIN;\n' "$U" | $PSQL -d postgres
$PSQL -d postgres -c "SELECT 1 / count(*) FROM pg_database WHERE datname = 'dedalo_$I'" >/dev/null 2>&1 || \
  printf "CREATE DATABASE \"dedalo_%s\" OWNER \"%s\" ENCODING 'UTF8' TEMPLATE template0;\n" "$I" "$U" | $PSQL -d postgres
printf 'REVOKE CONNECT, TEMPORARY ON DATABASE "dedalo_%s" FROM PUBLIC;\nGRANT CONNECT, TEMPORARY ON DATABASE "dedalo_%s" TO "%s";\n' "$I" "$I" "$U" | $PSQL -d postgres

PostgreSQL lets every role connect to a new database and create temporary tables in it (the PUBLIC grant). The last line takes that away and gives it to the instance's role alone, so another instance's role cannot reach this database.

Verify, as the installer does — the service user reaches its database by peer, and the three extensions the seed needs are available:

$RU $PGBIN/psql -X -q -A -t -h /var/run/postgresql -p 5432 -d "dedalo_$I" -c 'SELECT 1'     # 1
$PSQL -d postgres -c "SELECT count(*) FROM pg_available_extensions WHERE name IN ('btree_gin', 'pg_trgm', 'unaccent')"   # 3
$PSQL -d postgres -c "SELECT has_database_privilege('public', 'dedalo_$I', 'CONNECT')"                               # f

The || guards only make a re-run of this page harmless after it stopped halfway. They never adopt a role or a database that existed before: host_preflight checked that neither did.

A cluster whose pg_hba.conf refuses local peer connections fails the first check. Allow local all all peer (the distributions' default), then reload PostgreSQL. pg_hba.conf is global configuration: change it deliberately, for every database on the host.

host_engine — the engine steps, as the service user

Installer: Production › host_engine.

The engine steps fill the database and write the configuration: the seed, the ontologies, the thesauri, the languages, the tools, the root password, and <dir>/private/.env. They are not reproduced by hand. You run them with the installer itself, as the service user, with --engine-only: only the engine steps run, with no host step and no root.

cd "$DIR/dedalo"
$RU /usr/bin/env HOME="$DIR" "/opt/dedalo/bun/$PIN/bun" run "$DIR/dedalo/scripts/install.ts" \
  --route production --instance "$I" --engine-only \
  --db-user "$U" --pg-bin-path "$PGBIN" --media-access-mode publication \
  --backup-dir "$BACKUP_DIR" \
  --entity prado --entity-label 'Museo del Prado' --locale es-ES --timezone Europe/Madrid
  • --route production --instance "$I" gives the Production defaults that follow the instance name: the database dedalo_<i> over the socket /var/run/postgresql, MEDIA_PATH=/srv/dedalo/<i>/dedalo/media, and the socket /run/dedalo-<i>/dedalo_ts.sock.
  • --db-user "$U" is required whenever the service user is not ded_<i>: the database user is the service user, and the default would be ded_<i>.
  • --pg-bin-path "$PGBIN" is written as DEDALO_PG_BIN_PATH, so the backups dump with the PostgreSQL 18 client.
  • --backup-dir "$BACKUP_DIR" is written as DEDALO_BACKUP_DIR=$BACKUP_DIR/db, the directory the backup units write. The server's own backups and its directory check then use that directory too, and the instance has one backup location.
  • --media-access-mode publication is the Production default, spelled out so the choice is visible. The installer writes it as DEDALO_MEDIA_ACCESS_MODE, with or without the flag, as the installer's own route does, so the first boot does not report an unconfigured mode. Choose private or false here if that is your choice: Production › The media access mode.
  • Add any other answer as a flag or with --answers <file>, as on the installer's headless form: --langs, --app-lang, --data-lang, --ontologies, --hierarchies, --media-access-mode, --diffusion --mysql-*, --mailer --smtp-*. Every flag is in the installer reference. --plan prints the plan and changes nothing.

Run this way, at a terminal and without --execute, the installer asks for the root password (hidden, typed twice, checked against the policy). The password never appears on a command line or in the environment. For an unattended run, add --execute --secrets-stdin and pipe the secrets in as one JSON object, {"root_password":"…"}, plus mysql_password or smtp_pass when diffusion or the mailer is on. See Installer reference › Secrets. Avoid DEDALO_INSTALL_ROOT_PASSWORD here: exported in the root shell, the password stays in that shell's environment for every later command on this page, and typed inline it lands in the shell history.

The run prints the generated DEDALO_SALT_STRING once. Keep it with your backups.

Verify:

cd "$DIR/dedalo" && $RU /usr/bin/env HOME="$DIR" "/opt/dedalo/bun/$PIN/bun" run "$DIR/dedalo/scripts/install.ts" --status --json
# {"exists":true,"outcome":"complete","failed_step":null,"needs":[],"salt_generated":true,"running":null}

If it fails, fix the cause and run the same command with --resume. A database it filled is undone with --abort --confirm-database dedalo_<i>, which resets it to empty and refuses a completed install.

host_units — the systemd units

Installer: Production › host_units.

The templates. The units are shared by every instance on the host, and %i is the instance. Their only placeholder is the PostgreSQL unit. A template that already exists (another instance's) must be byte-equal to yours. If it differs, stop: the installer refuses to overwrite it, and so should you.

for unit in dedalo-ts@.service dedalo-ts-restart@.service dedalo-ts-rollback@.service dedalo-ts-watchdog@.service dedalo-ts-watchdog@.timer dedalo-backup@.service dedalo-backup@.timer dedalo-backup-alert@.service dedalo-backup-site-builder@.service dedalo-tls-renew@.service dedalo-tls-renew@.timer; do
  text=$(render "$SRC/deploy/systemd/$unit") && [ -n "$text" ] ||
    { echo "STOP: nothing rendered for $unit — define render first (see Before you start)"; break; }
  if [ -e "/etc/systemd/system/$unit" ]; then
    printf '%s\n' "$text" | cmp - "/etc/systemd/system/$unit" || echo "STOP: $unit differs"
  else
    printf '%s\n' "$text" > "/etc/systemd/system/$unit"
  fi
done

Nothing is written when render is missing or renders nothing: an empty template would otherwise stay behind, and a second run of the loop would report it as differs. The templates name no user and no path of an instance. Each instance-run template runs /bin/false until its drop-in gives it a command, so a template without its drop-in never runs anything as root.

The drop-ins are this instance's own. Each one goes to /etc/systemd/system/<unit>@<i>.<type>.d/10-dedalo-install.conf. The server: the account, the folder, the pinned runtime, the client publish directory.

install -d -m 0755 "/etc/systemd/system/dedalo-ts@$I.service.d"
render > "/etc/systemd/system/dedalo-ts@$I.service.d/10-dedalo-install.conf" <<'EOF'
# dedalo-install @@INSTANCE@@ manual
[Service]
User=@@USER@@
Group=@@GROUP@@
WorkingDirectory=@@DIR@@/dedalo
Environment="HOME=@@DIR@@"
ExecStart=
ExecStart=/opt/dedalo/bun/@@PIN@@/bun run src/server.ts
Environment="DEDALO_CLIENT_PUBLISH_DIR=/srv/dedalo/@@INSTANCE@@/client"
EOF

The backup set: three stores — the database, the media and the private directory — each from the root-owned bin directory, all run as the service user, with the .env as their environment:

install -d -m 0755 "/etc/systemd/system/dedalo-backup@$I.service.d"
render > "/etc/systemd/system/dedalo-backup@$I.service.d/10-dedalo-install.conf" <<'EOF'
# dedalo-install @@INSTANCE@@ manual
[Service]
User=@@USER@@
Group=@@GROUP@@
EnvironmentFile=
EnvironmentFile=@@DIR@@/private/.env
Environment="DEDALO_BACKUP_STATE_DIR=@@BACKUP_DIR@@/state"
Environment="DEDALO_BACKUP_EXPECTED_STORES=matrix_db media private"
ExecStart=
ExecStart=-/opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-backup-step.sh run matrix_db /opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-db-backup.sh --label timer --dir-key DEDALO_BACKUP_DIR --dir @@BACKUP_DIR@@/db --db-key DB_NAME --host-key DB_HOST --port-key DB_PORT --user-key DB_USER --password-key DB_PASSWORD --socket-key DB_SOCKET --pg-dump @@PG_BIN@@/pg_dump --pg-restore @@PG_BIN@@/pg_restore
ExecStart=-/opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-backup-step.sh run media /opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-tree-backup.sh --label media --source-key MEDIA_PATH --dest @@BACKUP_DIR@@/media --keep 14
ExecStart=/opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-backup-step.sh run --last private /opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-tree-backup.sh --label private --source @@DIR@@/private/ --dest @@BACKUP_DIR@@/private --keep 14 --exclude backups --exclude /export_artifacts
EOF

To back up the site builder too (the installer's --backup-site-builder), add two lines right after the stamp: [Unit] and Wants=dedalo-backup-site-builder@<i>.service. That store runs as root in a unit of its own, with no EnvironmentFile. Never add a root (+) line to the backup set: systemd applies the set's EnvironmentFile= (a file the service user writes) to every line, root lines included.

The backup alert, the code rollback and the watchdog:

install -d -m 0755 "/etc/systemd/system/dedalo-backup-alert@$I.service.d"
render > "/etc/systemd/system/dedalo-backup-alert@$I.service.d/10-dedalo-install.conf" <<'EOF'
# dedalo-install @@INSTANCE@@ manual
[Service]
User=@@USER@@
Group=@@GROUP@@
Environment="DEDALO_BACKUP_STATE_DIR=@@BACKUP_DIR@@/state"
ExecStart=
ExecStart=/opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-backup-alert.sh dedalo-backup@@@INSTANCE@@.service
EOF
install -d -m 0755 "/etc/systemd/system/dedalo-ts-rollback@$I.service.d"
render > "/etc/systemd/system/dedalo-ts-rollback@$I.service.d/10-dedalo-install.conf" <<'EOF'
# dedalo-install @@INSTANCE@@ manual
[Service]
User=@@USER@@
Group=@@GROUP@@
ExecCondition=
ExecCondition=/opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-code-rollback.sh --check --app-dir @@DIR@@/dedalo --backup-root @@DIR@@/backups/code
ExecStart=
ExecStart=/opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-code-rollback.sh --no-restart --app-dir @@DIR@@/dedalo --backup-root @@DIR@@/backups/code --service dedalo-ts@@@INSTANCE@@ --socket /run/dedalo-@@INSTANCE@@/dedalo_ts.sock
EOF
install -d -m 0755 "/etc/systemd/system/dedalo-ts-watchdog@$I.service.d"
render > "/etc/systemd/system/dedalo-ts-watchdog@$I.service.d/10-dedalo-install.conf" <<'EOF'
# dedalo-install @@INSTANCE@@ manual
[Service]
ExecStart=
ExecStart=/opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-ts-watchdog.sh --socket /run/dedalo-@@INSTANCE@@/dedalo_ts.sock --app-dir @@DIR@@/dedalo --backup-root @@DIR@@/backups/code --service dedalo-ts@@@INSTANCE@@.service --grace 900 --restart-unit dedalo-ts-restart@@@INSTANCE@@.service --rollback-unit dedalo-ts-rollback@@@INSTANCE@@.service
EOF

The watchdog runs as root, so its drop-in names no user. The rollback and the watchdog name the release tree and the code-update backup root explicitly, and never read them from the .env.

Then reload systemd and enable the server, without starting it. Do not enable the watchdog or the backup timer yet:

/usr/bin/systemctl daemon-reload
/usr/bin/systemctl enable "dedalo-ts@$I.service"

Verify: systemctl cat "dedalo-ts@$I" shows the template and the drop-in; systemctl is-enabled "dedalo-ts@$I" prints enabled. What each unit does is in Production › host_units.

host_selinux — SELinux on the RHEL family

Installer: Production › host_selinux.

Only on the RHEL family, and only when getenforce prints Enforcing or Permissive. Elsewhere, skip to the last command of this section.

The file contexts. The web server reads the socket's directory and the web root; systemd reads the install folder's own subtrees, and the web server is denied them. Paths are written as regular expressions, so the install folder is escaped first. Add only the rules semanage fcontext -l -C does not list yet:

E=$(printf '%s\n' "$DIR" | sed 's/[][\.^$*+?(){}|]/\\&/g')     # /home/my_domain\.org/dedalo
/usr/sbin/semanage fcontext -a -t httpd_var_run_t "/run/dedalo-$I(/.*)?"
/usr/sbin/semanage fcontext -a -t httpd_sys_content_t "/srv/dedalo/$I(/.*)?"
/usr/sbin/semanage fcontext -a -t httpd_sys_content_t "/var/lib/dedalo-install/acme/$I(/.*)?"   # Let's Encrypt only
/usr/sbin/semanage fcontext -a -t var_lib_t "$E"                  # only if you CREATED the folder in host_identity
/usr/sbin/semanage fcontext -a -t var_lib_t "$E/dedalo(/.*)?"
/usr/sbin/semanage fcontext -a -t var_lib_t "$E/private(/.*)?"
/usr/sbin/semanage fcontext -a -t var_lib_t "$E/backups(/.*)?"

A backup directory outside the install folder gets its own var_lib_t rule, escaped the same way. The socket's rule uses the /run spelling. RHEL 10's policy maps /var/run onto /run (/etc/selinux/targeted/contexts/files/file_contexts.subs_dist), and semanage refuses a rule written on the /var/run side (conflicts with equivalency rule '/var/run /run'). systemd creates the socket's directory at every start, and the rule labels it then.

Relabel. Root relabels the web root; the service user relabels inside its own folder:

/usr/sbin/restorecon -RF "/srv/dedalo/$I"
/usr/sbin/restorecon -RF "/var/lib/dedalo-install/acme/$I"         # Let's Encrypt only
/usr/sbin/restorecon -F "$DIR"                                      # only if you created the folder; as $RU if the user did
$RU /usr/sbin/restorecon -RF "$DIR/dedalo"
$RU /usr/sbin/restorecon -RF "$DIR/private"
$RU /usr/sbin/restorecon -RF "$DIR/backups"

A folder you did not create, its parent and the account's other files (~/.ssh) are never relabelled.

The policy module gives the engine its own domain, dedalo_engine_t. The web server may connect to that domain's sockets and to no other service's. The module is shared by every instance on the host: when semodule -l already lists dedalo_engine, it must have been loaded from the same file. Otherwise, load it:

install -d -o root -g root -m 0700 /var/lib/dedalo-install/selinux
sed 's/@@STAMP@@/dedalo-install/' "$SRC/deploy/selinux/dedalo_engine.cil.tpl" > /var/lib/dedalo-install/selinux/dedalo_engine.cil
chmod 0600 /var/lib/dedalo-install/selinux/dedalo_engine.cil
/usr/sbin/semodule -X 400 -i /var/lib/dedalo-install/selinux/dedalo_engine.cil
/usr/sbin/restorecon -F "/opt/dedalo/bun/$PIN/bun"

No SELinux boolean is changed.

Verify: semodule -l | grep dedalo_engine; stat -c %C "/opt/dedalo/bun/$PIN/bun" contains dedalo_engine_exec_t; ls -Zd "/srv/dedalo/$I" "$DIR/dedalo" "$DIR/private".

On every system, the release's hand-over directory has served its purpose. Remove it:

cd / && /usr/bin/rm -rf "$REL"

host_first_boot — the first start

Installer: Production › host_first_boot.

Start the server, then wait until /health answers 200 on the socket and every database migration is recorded. The first boot runs the boot migrations, and on a large ontology that can take minutes. The installer waits up to 45 minutes.

/usr/bin/systemctl start "dedalo-ts@$I.service"
until [ "$(/usr/bin/curl -s -o /dev/null -w '%{http_code}' --max-time 10 --unix-socket "$SOCK" http://localhost/health)" = 200 ]; do
  sleep 3
done
cd "$DIR/dedalo" && $RU /usr/bin/env HOME="$DIR" "/opt/dedalo/bun/$PIN/bun" run scripts/ops/boot_status.ts --json
# {"applied":<n>,"pending":[]} — run it again until pending is empty

If the loop never ends, check whether the server refused to boot. systemctl show -p Result --value "dedalo-ts@$I" printing exit-code, with ExecMainStatus 78, means it is not installed for the unit's environment. Read the [boot] REFUSED line in journalctl -u "dedalo-ts@$I".

On the RHEL family with SELinux, the engine must run in its own domain:

/usr/bin/ps -o label= -p "$(systemctl show -p MainPID --value "dedalo-ts@$I")"   # …:dedalo_engine_t:…

Verify: systemctl status "dedalo-ts@$I" --no-pager shows active (running); ls -l "$SOCK" is srw-rw-rw-, owned by the service user and group. The server opens the socket itself; what guards it is its directory: ls -ld /run/dedalo-$I is drwxr-x---, service user and service group, so only the service group (the web server's user among it) reaches the socket.

host_watchdog — arm the watchdog

Installer: Production › host_watchdog.

Only now, after the first boot has finished: a watchdog armed earlier would kill the first boot in the middle of its migrations.

/usr/bin/systemctl enable --now "dedalo-ts-watchdog@$I.timer"

Verify: systemctl is-active "dedalo-ts-watchdog@$I.timer" prints active.

host_web_http — the plain-HTTP site (Let's Encrypt only)

Installer: Production › host_web_http.

With your institution's certificate, skip to host_tls.

The web server's user joins the service group, through which it reaches the socket, the media and the published client. A reload is enough, because the workers start again with the new membership:

id -nG "$WEBUSER" | grep -qw "$G" || /usr/sbin/usermod -aG "$G" "$WEBUSER"
install -d -o root -g root -m 0755 /var/www/empty        # Apache only: the empty DocumentRoot

Apache needs the modules ssl headers http2 rewrite proxy proxy_http. Check with apache2ctl -M on Debian and Ubuntu, or httpd -M on the RHEL family. Enable a missing one with a2enmod <module> && systemctl restart apache2 on Debian and Ubuntu (a restart, as a2enmod advises), or with its package and LoadModule line under /etc/httpd/conf.modules.d/ on the RHEL family. The installer checks them, lists the command on the Review's To do list, and stops at this step while one is missing; enabling modules is the system administrator's job. The installer never sets ServerTokens, a server-wide directive: set ServerTokens Prod in the global configuration yourself if you want the Server: header not to name the system and version.

Write the first stage of the site: the plain-HTTP server with the challenge root, and the redirect to HTTPS.

# nginx
render > "$SITE" <<'EOF'
# dedalo-install @@INSTANCE@@ manual
# Dédalo instance @@INSTANCE@@ — WRITTEN BY THE INSTALLER (scripts/install.ts, production
# route) or by docs/install/manual_install.md, which renders the same bytes. Every other
# site, the default site and the global configuration are untouched. An installer resume
# compares this file with its own render: change the answers and re-run the installer;
# a by-hand host edits it only where manual_install.md says.

limit_req_zone $binary_remote_addr zone=dedalo_@@INSTANCE@@_api:10m rate=20r/s;
upstream dedalo_@@INSTANCE@@ { server unix:/run/dedalo-@@INSTANCE@@/dedalo_ts.sock; }

server {
    listen 80;
    listen [::]:80;
    server_name @@SERVER_NAME@@;
    server_tokens off;
    location ^~ /.well-known/acme-challenge/ {
        root /var/lib/dedalo-install/acme/@@INSTANCE@@;
        default_type text/plain;
    }
    location / { return 301 https://$host$request_uri; }
}
EOF
# Apache
render > "$SITE" <<'EOF'
# dedalo-install @@INSTANCE@@ manual
# Dédalo instance @@INSTANCE@@ — WRITTEN BY THE INSTALLER (scripts/install.ts, production
# route) or by docs/install/manual_install.md, which renders the same bytes. Every other
# site, the default site and the global configuration are untouched. An installer resume
# compares this file with its own render: change the answers and re-run the installer;
# a by-hand host edits it only where manual_install.md says.

<VirtualHost *:80>
    ServerName @@SERVER_NAME@@
    DocumentRoot /var/www/empty
    Alias /.well-known/acme-challenge/ /var/lib/dedalo-install/acme/@@INSTANCE@@/.well-known/acme-challenge/
    <Directory /var/lib/dedalo-install/acme/@@INSTANCE@@>
        Require all granted
    </Directory>
    RewriteEngine On
    # Only this site's own name: any other Host is refused (421), never served.
    RewriteCond %{HTTP_HOST} !^@@SERVER_NAME_RE@@(:80)?$ [NC]
    RewriteRule ^ - [R=421,L]
    RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
    RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
</VirtualHost>
EOF
/usr/sbin/a2ensite "dedalo-$I"                             # Debian and Ubuntu only

Then test, and reload. A reload applies every pending change of the web server's configuration, not only this site's:

/usr/sbin/nginx -t && /usr/bin/systemctl reload "$WEBSVC"            # nginx
/usr/sbin/apachectl configtest && /usr/bin/systemctl reload "$WEBSVC" # Apache

Verify: curl -I "http://$NAME/" answers with a redirect to https://.

host_tls — the certificate

Installer: Production › host_tls.

Let's Encrypt. One certificate per instance, named dedalo-<i>, renewed by the distribution's timer. Its deploy hook reloads the web server:

test -s "/etc/letsencrypt/live/dedalo-$I/fullchain.pem" || \
  /usr/bin/certbot certonly --webroot -w "/var/lib/dedalo-install/acme/$I" -d "$NAME" -m "$EMAIL" \
    --agree-tos --non-interactive --cert-name "dedalo-$I" --deploy-hook "systemctl reload $WEBSVC"
/usr/bin/systemctl enable --now "$CERTBOT_TIMER"

Add --staging to try the challenge against the staging authority first. Its certificate is not trusted by browsers, so issue the real one afterwards (certbot certonly … --cert-name "dedalo-$I" --force-renewal without --staging). A failed request is almost always DNS or port 80: see Troubleshooting › Let's Encrypt.

A local certificate authority (an intranet with no public name, a test machine without a domain). The root-owned copy of deploy/dedalo-tls-rotate.sh from host_runtime makes it — the same script the docker route and every rotation use, so do not write certificates with openssl by hand. Give every address staff type as a --host, the public address first: the authority certifies exactly those names, never one below them, so no name may lie inside another (adding one later means a new authority, and the name in the site file you write in host_web_site). The certificate directory is root's; the script makes the keys 0600:

TLS=/opt/dedalo/instances/$I/tls
install -d -o root -g root -m 0755 "$TLS"
test -s "$TLS/fullchain.pem" -a -s "$TLS/dedalo-local-ca.pem" || \
  "$BIN/dedalo-tls-rotate.sh" --mode local-ca --host "$NAME" --dir "$TLS" --no-reload --quiet
# more addresses: --host "$NAME" --host dedalo.lan (names only by hand: an IP address needs the
# installer, which claims it in the host's catch-all — reverse_proxy.md, Reaching an instance by IP address)
[ "$SELINUX_MODULE" = true ] && semanage fcontext -a -t cert_t "$TLS(/.*)?" && restorecon -RF "$TLS"

In the full site of host_web_site, the certificate lines name $TLS/fullchain.pem and $TLS/privkey.pem instead of the Let's Encrypt paths, and every extra address joins the server name: nginx's server_name lists them all; Apache adds ServerAlias <the others> under ServerName and its name guard reads !^(<name>|<other>…)(:<port>)?$, each name with its dots escaped.

The certificate renews itself from the same authority, so the staff computers never trust anything again: the drop-in of the renewal unit, then its timer (a daily check; the script acts only in the certificate's last 30 days, then reloads the web server):

mkdir -p "/etc/systemd/system/dedalo-tls-renew@$I.service.d"
render > "/etc/systemd/system/dedalo-tls-renew@$I.service.d/10-dedalo-install.conf" <<'EOF'
# dedalo-install @@INSTANCE@@ manual
[Service]
ExecStart=
ExecStart=/opt/dedalo/instances/@@INSTANCE@@/bin/dedalo-tls-rotate.sh --mode renew --dir /opt/dedalo/instances/@@INSTANCE@@/tls --reload systemd --unit @@WEB_SERVICE@@
EOF
/usr/bin/systemctl daemon-reload
/usr/bin/systemctl enable --now "dedalo-tls-renew@$I.timer"

Then trust $TLS/dedalo-local-ca.pem on every staff computer, once — Production › Done says how on each system. openssl x509 -in "$TLS/dedalo-local-ca.pem" -noout -fingerprint -sha256 prints the fingerprint to compare there.

Your institution's files are used where they are, never copied. Check that they name the address, are valid now (renew under 30 days) and pair with each other:

openssl x509 -in <fullchain> -noout -subject -ext subjectAltName -enddate
[ "$(openssl x509 -in <fullchain> -noout -pubkey | sha256sum)" = "$(openssl pkey -in <key> -pubout | sha256sum)" ] && echo pair

This host must trust the chain (the public probe verifies it with this host's curl): openssl verify -untrusted <fullchain> <fullchain> prints OK once your authority is in the system trust store. Until then it fails with unable to get local issuer certificate, or self-signed certificate in certificate chain when the file carries the authority's own certificate: install it as in host_preflight.

On the RHEL family, the web server must be able to read them: keep them under /etc/pki/tls, or label their directory cert_t (semanage fcontext -a -t cert_t '<dir>(/.*)?' && restorecon -RF <dir>).

Verify: with Let's Encrypt, certbot certificates --cert-name "dedalo-$I" and systemctl is-enabled "$CERTBOT_TIMER". With a local CA, openssl verify -CAfile "$TLS/dedalo-local-ca.pem" -untrusted "$TLS/dedalo-issuing-ca.pem" "$TLS/fullchain.pem" prints OK and systemctl is-enabled "dedalo-tls-renew@$I.timer" prints enabled. With your own files, openssl x509 -noout -subject -enddate -in <fullchain> names the address and a date ahead; once the site is up, `openssl s_client -connect "$NAME:443" -servername "$NAME"