Production install
See also: Installation hub · Installer reference · The web server site · Several instances on one host · Troubleshooting · Upgrading
The Production route is the recommended way to run Dédalo for a museum, an archive or any institution. The installer turns a clean server into a running, TLS-protected, supervised and backed-up instance: one named Dédalo with its own user, database, media, socket, units and web site. This page explains how to run it, what each step does, and how to check or repair each step by hand.
The installer is the recommended procedure. The sections on each step are reference for system administrators: what to look at, and what the installer has already done. Each one links its hand version: Install by hand is the same procedure, one command at a time, for administrators who install by hand.
Overview
The engine is a single long-lived Bun process, run by systemd as the instance's own user. It listens on a unix socket. The web server (nginx or Apache) owns TCP and TLS, serves the published client files and the media bytes, and enforces media access control. PostgreSQL 18 on the same host is the system of record.
Everything the installer creates is named after the instance name you choose (<i> below — for example prado), except the install folder and the service account, which you may choose yourself (The install folder and the service account). The table shows the defaults; <dir> is the install folder:
| Path or name | What |
|---|---|
ded_<i> |
the service user and group the instance runs as (default; a new system account with no login shell) |
<dir> = /home/ded_<i>/ |
the install folder (default) |
<dir>/dedalo/ |
the release tree (an extracted release, not a git clone) |
<dir>/private/ |
the private directory (0700): .env (0600), the session store, the state file, the engine's install record |
<dir>/backups/ |
the backups (the default --backup-dir) and the code-update backups (backups/code) |
/srv/dedalo/<i>/ |
the web site's root (0750) |
/srv/dedalo/<i>/dedalo/media/ |
MEDIA_PATH: originals, derivatives and the media-gate files (0750, never world-readable) |
/srv/dedalo/<i>/client/ |
the client the engine publishes at every start; the web server serves it from here |
/run/dedalo-<i>/dedalo_ts.sock |
the engine's socket (systemd RuntimeDirectory) |
dedalo_<i> |
the database, owned by the role named like the service user (peer authentication, no password) |
dedalo-ts@<i> |
the engine's systemd unit, with its watchdog, rollback, restart and backup units |
/opt/dedalo/bun/<pin>/bun |
the Bun runtime at the release's pin, owned by root and shared by the instances on that pin |
/opt/dedalo/instances/<i>/bin/ |
the root-owned copies of the scripts root-run units execute |
/var/lib/dedalo-install/<i>/ |
the installer's record of the host steps (0700, root) |
The layout follows one rule, by role. The install folder is the instance directory: owned by the service user, it holds the release tree, the private directory and the backups, and an in-app code update renames the release tree only inside it (its parent is never changed). What root-started units run — the runtime and the root-run scripts — lives in root-owned directories under /opt/dedalo/, outside every instance directory. A host installed by hand follows the same rule and the same paths.
Several instances can share one host; each runs the installer with its own name. See Several instances on one host.
Mandatory: a supervisor that declares itself
Dédalo must run under a process manager that restarts it and declares DEDALO_SUPERVISED=true in the process environment. A code update replaces the tree and then exits the server with code 75, so that the supervisor boots the new code; without a declared supervisor the code update panel refuses every update (No supervisor declared) rather than leave the server dead.
| Launch method | Where the declaration is |
|---|---|
this route's dedalo-ts@<i> unit |
Environment="DEDALO_SUPERVISED=true", with Restart=always and SuccessExitStatus=75 — written by the installer |
| the compose stacks | the dedalo service's environment: |
| a host installed by hand | the same dedalo-ts@<i> template and drop-in, written by hand (a host set up before 2026-10-12: the single-unit set, deploy/dedalo-ts.service) |
| development | bun run start:supervised or bun run dev; plain bun run start is unsupervised |
A DEDALO_SUPERVISED line in .env is ignored: that file is read by every launch method, the unsupervised bun run start included.
Before you start
A supported system. Ubuntu 26.04 LTS, Debian 13, or RHEL, Rocky Linux or AlmaLinux 10, with systemd. The installer supports the newest release of each and refuses anything else, before it changes anything. You run it as root.
The bootstrap's tools. curl, unzip and sha256sum, which install.sh needs before the installer can install anything: apt-get install curl unzip coreutils or dnf install curl unzip coreutils. See Get Dédalo.
The release: an archive or a checkout. Run the installer from a release archive or from a git clone checked out at a release tag. A clone usually belongs to the account you cloned it as, not to root. The installer handles that: install.sh installs its own dependencies as the clone's owner, so root writes nothing in your clone, and host_runtime archives the checked-out commit with that one checkout marked safe for git, without changing any global git setting. A --route production run on a system the route does not support is refused before anything is downloaded.
An instance name. A lowercase letter, then lowercase letters, digits or _, at most 28 characters. It becomes the database dedalo_<i>, the units, the web paths and the default service user ded_<i>, and cannot be changed later.
The install folder and the service account
The Identity screen asks, right after the instance name, where the instance lives and which account it runs as. Each question is pre-filled; Enter accepts it.
| Question | Flag | Default | What you may give |
|---|---|---|---|
| Install folder | --install-dir |
/home/ded_<i> |
any absolute path, for example /home/my_domain.org/dedalo |
| Service user | --service-user |
ded_<i> |
a new name (the installer creates it) or an existing account, used as it is |
| Service group | --service-group |
the service user's own group | another group; a new name is created |
The install folder. When it does not exist, the installer creates it, owned by the service user (0750). Its parent is never changed: it may be root's, even 0555. An existing folder is used only when it is the service user's and holds no install (dedalo/, private/, backups/), or when it is root's, empty, under root-owned parents and inside /home, /srv, /data, /mnt or /media (a mount point, say) — then it is handed to the service user, and --abort gives it back with its owner and mode. An empty directory of the system (/var/mail, /var/opt, …) or of another account is never taken over: chown it to the service user yourself first. A folder writable by group or others, one reached through a symbolic link, one below a directory another account controls, a system location (/etc, /usr, /var/lib and the like) and a folder that a web site on this host serves are refused. The backups default to <dir>/backups; a --backup-dir is a normalized absolute path (no .., no //, no trailing /) that does not exist yet — inside the folder too: the installer never re-modes or fills a directory that was already there.
The service user. The default is a new system account of its own, which is the recommended choice. An existing account — a hosting account such as my_domain.org — is used as it is: the installer never creates, changes or deletes it, and --abort keeps it. It must be a regular account (uid in the range of /etc/login.defs), never root, and in no privileged group (wheel, sudo, docker, lxd, or any system group except the common users): systemd gives the service every group of the account. The accounts of the packages the installer installs (postgres, www-data, nginx, apache) are refused as service user and as service group. An account with a login shell is accepted; the Review says that whoever logs in as it can change the code the service runs. The installer creates only names that useradd accepts by default (no dot); an existing account's name may contain a dot.
The service group. The web server's user joins it, to reach the socket, the media and the published client. When the service group is an existing account's primary group (by default, or typed), the web server can then read whatever that group can read: the Review says so. A group of its own (--service-group ded_<i>) is recommended.
One account, one group, one folder per instance. The System check refuses a service user or group that another Dédalo instance on this host already uses, an account that is a member of another instance's group, and an install folder that is, contains or lies inside another instance's folder. The other instances are read from the host registry (/var/lib/dedalo-install/instances/), the units' drop-ins, the installer's records of unfinished installs, and any hand-installed dedalo*.service (with no Group= line, its group is its User='s primary group; when that cannot be resolved, the unit is refused). The refusal names the file it read. A name an installed instance already holds (its registry entry or its unit's drop-in) is refused on the System check, right after you type it, naming those files: choose another name — an install never takes over an installed instance, its account, folder or database. An account that did not exist at the System check but exists when host_identity runs (a package created it) is refused, never taken for the installer's own.
The database user is the service user: PostgreSQL's peer authentication maps the operating-system user to the role of the same name. With --db-mode create the database user is never asked, and --db-user is refused when it differs from the service user. When the account already has a role of its name, use --db-mode existing.
The public address. A fully qualified name (not localhost, not an IP address) whose DNS points at this server. It goes into the site file and the certificate. With a local certificate authority it needs to resolve only where staff work (the intranet's DNS, or each computer's hosts file), and Other addresses (--tls-extra-names dedalo.lan,archivo.museo.lan) adds the other names staff type: the site answers to them and the certificate names them. Other addresses may also list IPv4 addresses of this server (--tls-extra-names dedalo.lan,192.168.1.20), for an intranet with no local DNS: the instance claims them, and https://192.168.1.20/ reaches it (Reaching an instance by IP address). The System check refuses an address that is not this server's, one another instance claims, and IPv6.
The certificate.
| Choice | What you need |
|---|---|
| Let's Encrypt (default) | the name resolves publicly to this server, port 80 is reachable from the internet (the HTTP-01 challenge), and an email address for expiry notices |
| Local CA | nothing outside this server: for an intranet with no public name, or a test machine without a domain (Trying the production route in a virtual machine). The installer makes a certificate authority on this server and a certificate for the address (and the other addresses). Each staff computer then trusts the authority once — the Done screen says where its file is, its fingerprint and how. The authority can certify exactly this install's addresses, localhost and 127.0.0.1 — not even a name below one of them, such as mail.<address> — so a stolen key cannot impersonate another website or another machine of your network; for the same reason no address may lie inside another (museo.lan and dedalo.museo.lan together are refused). The certificate renews itself from the same authority, so the staff computers never trust anything again |
| Your institution's certificate | the fullchain and the key as files on this server. They must name the address, be valid now (a warning under 30 days, shown on the Review) and pair with each other, and this server must trust the chain: the installer's last step checks the site with this machine's curl, exactly as a browser would. A certificate from your institution's own certificate authority needs that authority in the system trust store first (Debian and Ubuntu: copy it to /usr/local/share/ca-certificates/<name>.crt, then sudo update-ca-certificates; RHEL family: copy it to /etc/pki/ca-trust/source/anchors/, then sudo update-ca-trust). The System check refuses an untrusted chain and names its issuer. The installer uses the files where they are and never copies or changes them |
The web server. nginx (at least 1.25.1) or Apache. On a host that has one already, choose that one; when there is none, the installer installs the one you choose on the Web server & TLS screen (nginx by default). It refuses, before any change, a run that would install the other web server beside an installed one, and a run whose ports (80 and 443, or yours) another program holds: another web server, such as another instance's, a proxy or a container. The Review's To do list names that program before you type yes. It writes one site file for this instance and, on nginx, once per host the catch-all site that keeps a name no site declares away from every instance (on Apache, each site refuses any name but its own). It never edits another site, another default site or the global configuration.
Apache needs the modules ssl headers http2 rewrite proxy proxy_http. Enabling modules is your job: the installer checks them and never enables one. On the RHEL family the httpd, mod_ssl and mod_http2 packages load them all. On Debian and Ubuntu the apache2 package enables none of them, so the Review's To do list prints the command, a2enmod ssl headers http2 rewrite proxy proxy_http && systemctl restart apache2. When the installer installs Apache itself, the run stops at the web step until you run it, then --resume continues. To avoid that stop, install apache2 and enable the modules before you start.
The firewall. The site needs ports 80 and 443 (or your --http-port and --https-port) open to the browsers that will use it. The installer never changes the firewall. It reads firewalld's default zone or ufw's rules and shows the result on the System check and the Review. When a port is closed, the Review's To do list and the closing To do on this host list print the command that opens it, for example firewall-cmd --permanent --add-service=http --add-service=https && firewall-cmd --reload or ufw allow 80,443/tcp. Its last probe connects from the server itself, so it passes even when the firewall blocks everyone else: check from another machine too.
The database. By default (--db-mode create) the installer creates the role and the database on this host's PostgreSQL 18. With --db-mode existing it uses a database you created, which must be empty.
The media access mode. Media is served by the web server, but the access rules are generated by Dédalo. Decide which readers the web server lets through:
DEDALO_MEDIA_ACCESS_MODE |
Who can read a media file |
|---|---|
| (unset) | the publication default — fail-closed |
private |
logged-in Dédalo users only |
publication |
logged-in users plus anonymous readers of published records, in public quality folders only |
false |
everybody: the media tree is served openly, with no gate at all |
Three facts about the gate you cannot guess:
- No Dédalo process is ever in the media byte path. Authorisation is a single
stat()performed by the web server, which is whysendfile, HTTPRangeand the H.264?start=clipping still work on multi-gigabyte files. - The gate fails closed as
404, never403. The existence of unpublished media is not disclosed. - Master qualities can never be made public. The
originalandmodifiedfolders are filtered out of the public list whatever you configure.
How the two rules work is in The web server site.
Run the installer
From the release of Dédalo (Get Dédalo), as root:
sudo sh install.sh
Choose Production on the first screen and type the instance name there (--plain asks the same questions one per line). Give no other flag: any answer flag, --route and --instance included, makes the run headless — see the headless form below. The screens ask, in order:
| Screen | What it asks |
|---|---|
| Install type | the route and the instance name |
| System check | nothing: it shows what this host has, and whether an installed instance already holds the name. A failed row must be fixed before the install |
| Identity | the install folder, the service user and the service group first (the database user is the service user, so they come before the database); then the institution's short code (entity) and full name, the install fingerprint (Information and Info key — keep the defaults unless you are moving a v6 installation; they never change later), the time zone and the locale. The time zone stamps every database timestamp |
| Database | the database mode: create (the default — only the database name is asked: the role is the service user, reached by peer login, with no password) or existing (its host, port, socket, name, role and password). Defaults follow the instance name |
| Web server & TLS | nginx or Apache, the public address, the certificate (Let's Encrypt and its email, a local CA and its other addresses, or the paths of your files), then the HTTP and HTTPS ports |
| Services | the media root, the socket, the media access mode and the backup directory. The defaults are the layout above |
| Ontologies | whether to use the official update servers, then the domain ontologies: Oral history (oh) is built in and pre-ticked; others come from the update server, with the ontologies they declare |
| Thesauri | the thesauri the chosen ontologies declare, pre-ticked. No country's toponymy is pre-selected. The thesauri are created empty: only lg and the country toponymies carry terms |
| Languages | the interface and data languages |
| Publication | optional: a MariaDB publication target (tested, or kept untested by typing yes) |
| optional: an SMTP relay for password recovery (tested the same way) | |
| Administrator | the root password, typed twice and checked against the password policy |
| Review plan | everything that will happen — see below |
The same install runs headless, for automation. Give the answers as flags or as an answers file and the root password through the environment, never on the command line:
read -rsp 'New root password: ' DEDALO_INSTALL_ROOT_PASSWORD; echo; export DEDALO_INSTALL_ROOT_PASSWORD
sudo --preserve-env=DEDALO_INSTALL_ROOT_PASSWORD sh install.sh -- \
--route production --instance prado --entity prado \
--server-name dedalo.example.org --letsencrypt-email it@example.org --execute
unset DEDALO_INSTALL_ROOT_PASSWORD
Every flag, the answers file and --plan (a dry run that changes nothing) are in the installer reference.
The Review
Nothing has changed on the host until you type yes at the Review. It shows the answers, the ontologies in install order, the thesauri, the .env key names, and every command each host step will run, exactly as it will run — the run executes this same list, and a resume refuses a step whose commands have changed since. Read it: it is the whole change to the host.
Before the steps comes a To do list, when there is anything on it: what you must do yourself, because the installer checks it but never does it. It lists the firewall's closed ports with the command that opens them, the Apache modules to enable, and an institution certificate that expires within 30 days or that this server does not trust. A directory a step would create that already exists, such as /srv/dedalo or /opt/dedalo on a host with other instances, is marked exists, not created next to its step and on its own mkdir line: the step does not create it, and sets the mode and owner the line shows.
write <path> (or w on the full screen) saves your answers as an answers file, with no secret in it, instead of installing.
The steps
The installer runs the steps below in order. Each one is recorded in /var/lib/dedalo-install/<i>/ before and after it runs. If a step fails, the installer stops, says why, and prints the two ways on:
sudo sh install.sh --route production --instance <i> --resume # fix the cause, then continue
sudo sh install.sh --route production --instance <i> --abort --confirm-database dedalo_<i> # undo this install
--resume checks again every step already recorded as done that has a check (done in an earlier run — verified again: …), starts again a step that only starts a service and finds it stopped, refuses a step whose work was undone outside the installer, and runs the rest. A done step with nothing to check, such as the System check, is not run again (… — nothing to re-check, not run again). --abort undoes, in reverse order, only what this install created. Neither ever touches something that existed before the install. Both are described in full in the installer reference.
On every step below, Verify is what you can check by hand, and Repair is what to do when that check fails.
host_preflight — the System check
By hand: Install by hand › host_preflight.
Measures the host and refuses, before any change, everything a later step would trip on: an unsupported system (<system> is not supported — the production route supports …), no systemd, not root, nginx older than 1.25.1, anything already using this instance's names (the database or role, the unit, its drop-ins, the site file, the socket, the nginx upstream and rate-limit zone names), every install folder and service account rule (another instance's user, group or folder; a system account; a privileged group; a folder that is not empty and not the service user's), any instance directory that already exists — under /srv/dedalo/<i>, the backup dir, private/ (the installer never takes over a directory), a web-server configuration it cannot read, the address already served by another site on these ports, the instance's ports held by any program but the chosen web server (another web server, such as another instance's, a proxy, a container), the other web server installed beside the one this run would install, another PostgreSQL major version on the database port, and an institution certificate that does not check out (another address, expired, another key, or a chain this server does not trust). It reads the firewall (firewalld's default zone, or ufw) and tells you whether the instance's ports are open, with the command when they are not; it never changes the firewall. On Apache it also tells you which modules to enable (see The web server under Before you start).
It changes nothing, so it has no undo. Then it freezes what it measured and the command list of every later step.
- Verify: the refusal sentence lists every problem at once.
- Repair: resolve each, then run the same command again — nothing was written, so it starts fresh. Open the ports yourself when the firewall line says they are closed; it prints the command. See Troubleshooting › The System check refuses.
host_packages — packages and repositories
By hand: Install by hand › host_packages.
Installs only what the host is missing: PostgreSQL 18 (server, client, contrib), the media toolchain, the archive tools (git, unzip, gzip, file, curl, rsync, diffutils, ca-certificates), the web server you chose when there is none, certbot for Let's Encrypt, and the SELinux management tools on the RHEL family. It adds the repositories they need:
| System | Repositories |
|---|---|
| Ubuntu 26.04 | none — PostgreSQL 18 ships with the release |
| Debian 13 | the PostgreSQL project's repository (PGDG) |
| RHEL / Rocky / AlmaLinux 10 | EPEL (ImageMagick, certbot), CodeReady Builder, RPM Fusion free (FFmpeg), PGDG. ocrmypdf is optional there: a failure to install it only warns, and automatic OCR of PDFs stays off. EPEL 10 has no ocrmypdf for aarch64 |
The media toolchain, and what each tool is for:
| Tool | Used for |
|---|---|
ffmpeg / ffprobe |
audiovisual transcoding, posterframes, probing |
qt-faststart |
moves the MP4 index to the front so a video starts before it has downloaded |
| ImageMagick | image derivatives, thumbnails, colour-space conversion |
pdftotext, pdftohtml, pdfinfo (poppler) |
PDF text extraction and page rendering |
ocrmypdf |
optional automatic OCR of uploaded PDFs |
rsvg-convert (librsvg) |
thumbnails of SVG records — the only vector rasterizer Dédalo uses |
ImageMagick can render SVG only through a coder that Dédalo's hardened ImageMagick policy disables, because that coder is a long-standing remote-code-execution vector. Vector rendering therefore goes through rsvg-convert, which parses SVG and nothing else.
Packages and repositories are shared host state: --abort tells you what this install added and never removes it (removing a repository would leave its packages without security updates).
- Verify:
command -v ffmpeg ffprobe qt-faststart pdftotext rsvg-convert, andpsql --version(18). Ifqt-faststartlives elsewhere, setDEDALO_AV_FASTSTART_PATHin.env. For OCR in another language, install its Tesseract pack (tesseract-ocr-spa…); without it OCR falls back to English. - Repair: a failed download or a locked package manager: fix it, then
--resume.
host_identity — the service account, the install folder and the directories
By hand: Install by hand › host_identity.
Creates the service user when it does not exist (a system account, no login shell, no skeleton files) with its own group, or the typed group first (groupadd --system); an existing account and an existing group are used as they are and appear on the Review as USED AS IS. Then every directory in the layout at its mode: the install folder (0750), private (0700), backups, /srv/dedalo/<i> and the media root (0750), and the root-owned /opt/dedalo/… directories (0755).
Root creates the install folder only under root-owned parents (its missing parents root 0755). Under a parent the service user owns — /home/my_domain.org — the service user creates it. Everything inside the folder and inside the instance's web root is created by the service user: root never works by path in a tree another account controls. A shared directory that already exists (/srv/dedalo or /opt/dedalo on a host with other instances) is used as it is and never re-moded.
- Verify:
getent passwd <service user>;sudo stat -c '%a %U:%G %n' <dir> <dir>/private /srv/dedalo/<i>/dedalo/mediashows750,700and750, owned by the service user (sudo: the folder is0750, so only root and the service account read inside it). - Repair:
--resume.--abortremoves, as the service user, what this install left in the folder, then the folder when it created it (an adopted empty folder gets its owner and mode back; a folder used as it is keeps its own content). It removes the user and the group only when it created them — never an existing account — after removing, as that user, what it left at the top of/tmpand/var/tmp(Bun'sbun-node-<hash>directory and.<hash>-0.hmmarker), and keeps them, telling you why, while the user still runs processes or backups remain (a removed user id could be given to a later account, which would then read them).
host_runtime — Bun, the release and the root-run scripts
By hand: Install by hand › host_runtime.
- Bun at the release's pin, owned by root, in
/opt/dedalo/bun/<pin>/. It is taken only from a zip whose sha256 equals the release's committed.bun-sha256row — the bootstrap's verified cache. The unit runs it as the instance, but the instance can never replace what it runs. When another instance already runs the same pin, the binary there is byte-identical (cmp) and is not installed again. - The release extracted into
<dir>/dedaloby the service user (from a root-owned hand-over directory under/var/lib/dedalo-install/), and its dependencies installed as the service user (bun install --frozen-lockfile --production --ignore-scripts). From a checkout, the release is its checked-out commit (git -c safe.directory=<checkout> -c core.fsmonitor=false archive … HEAD). Thesafe.directoryexception applies to this one command and changes nogitconfiguration. Whengitrefuses, the step's message quotes itsfatal:line. - The root-run scripts — backup, watchdog, rollback, alert and media-rule scripts, and the local-CA generator
dedalo-tls-rotate.sh— copied root-owned (0755) to/opt/dedalo/instances/<i>/bin/, outside the code tree: a code update moves the whole tree aside, and the rollback has to work exactly when the tree is missing.
Never bun upgrade on a production server
The engine is coupled to version-specific runtime behaviour — JSONB parameter inference in Bun.sql above all — and a silent drift there is a data-corruption class, not a performance regression. The server echoes its runtime at boot and warns loudly when it does not match the pin. The rationale is in engineering/PRODUCTION.md §1.
- Verify:
/opt/dedalo/bun/<pin>/bun --versionequalssudo cat <dir>/dedalo/.bun-version(the install folder is0750);stat -c '%U %n' /opt/dedalo/bun/<pin>/bunisroot;ls /opt/dedalo/instances/<i>/bin/. - Repair:
--resume.--abortremoves the release and the scripts, and Bun only when no other instance uses that pin.
host_database — the role and the database
By hand: Install by hand › host_database.
With --db-mode create, it creates the role named like the service user (LOGIN, no password — peer authentication over the local socket /var/run/postgresql, so no database password exists at rest) and the database dedalo_<i> it owns (UTF8, from template0). PostgreSQL lets every role connect to a new database (the PUBLIC grant), so the installer then revokes CONNECT and TEMPORARY from PUBLIC and grants them to the instance's role alone: another instance's role cannot reach this database. Then it checks that the instance user reaches its database by peer, and that the three extensions the seed needs (btree_gin, pg_trgm, unaccent) are available. With --db-mode existing it does nothing; the engine step probes your database with the password you gave.
- Verify:
sudo -u <service user> psql -h /var/run/postgresql -d dedalo_<i> -c 'SELECT current_user'prints the service user with no password asked;sudo -u postgres psql -Atc "SELECT has_database_privilege('public', 'dedalo_<i>', 'CONNECT')"printsf. - Repair: a cluster whose
pg_hba.confrefuses local peer connections is reported, not edited —pg_hba.confis global configuration. Allowlocal all all peer(the distribution default), reload PostgreSQL, then--resume.--abortdrops only a role and database this install created, only when the database is empty, and only with--confirm-database.
host_engine — the engine steps, as the instance user
By hand: Install by hand › host_engine.
The engine steps — the database seed, the ontologies, the thesauri, the root password, the tool registration — run in one child process, as the instance user, in the instance's own tree:
runuser -u <service user> -g <service group> -- /usr/bin/env HOME=<install folder> /opt/dedalo/bun/<pin>/bun run scripts/install.ts --request-stdin --execute --events-jsonl
The answers and the secrets travel as one JSON object on its standard input — never on a command line, never in a file. The child keeps its own record in <dir>/private/install/ and writes <dir>/private/.env. What each engine step does is in the installer reference.
- Verify:
sudo runuser -u <service user> -- sh -c 'cd <dir>/dedalo && HOME=<dir> exec /opt/dedalo/bun/<pin>/bun run scripts/install.ts --status --json'prints"outcome":"complete"(the installer's own form:HOMEset to the install folder, so Bun keeps no cache in another home). Thecdruns as the service user, inside the command: the install folder is0750, so your own shell cannot enter it. - Repair:
--resumeresumes the child too.--abortruns the child's own--abortfirst, which resets the database to empty — and refuses a completed (sealed) install.
host_units — the systemd units
By hand: Install by hand › host_units.
Writes the shared template units from deploy/systemd/ into /etc/systemd/system/ (one copy per host), and this instance's drop-ins (/etc/systemd/system/<unit>@<i>.service.d/10-dedalo-install.conf: the service user and group, the working directory and HOME in the install folder, the .env, the pinned runtime, the client publish directory, the backup store lines, and the rollback and watchdog paths). The templates name no user and no path of an instance: a template without its drop-in runs /bin/false, never anything as root. Then systemctl daemon-reload and systemctl enable dedalo-ts@<i> — not started, and with no watchdog or backup timer yet. A template that already exists with other contents is refused, never overwritten.
| Unit | What it does |
|---|---|
dedalo-ts@<i>.service |
the server: RuntimeDirectory=dedalo-<i>, UMask=0007, Restart=always, SuccessExitStatus=75, RestartPreventExitStatus=78, Environment="DEDALO_SUPERVISED=true" |
dedalo-ts-watchdog@<i>.timer + .service |
every 30 s, /health over the socket; on failure it restarts the server, or rolls back a code update that has not been confirmed yet |
dedalo-ts-restart@<i>.service |
the restart helper the watchdog fires |
dedalo-ts-rollback@<i>.service |
rolls a failed code update back to the previous tree; does nothing when no update is pending |
dedalo-backup@<i>.service + .timer |
the nightly backup set, run as the instance |
dedalo-backup-site-builder@<i>.service |
the site-builder store, run as root in its own unit (only with --backup-site-builder) |
dedalo-backup-alert@<i>.service |
reports a failed backup |
dedalo-tls-renew@<i>.timer + .service |
local CA only: renews the site certificate from the same authority (written here, enabled by host_tls) |
Why both Restart=always and the watchdog: the server reads its configuration once, at boot, so a configuration change needs a restart, and any crash must bring the process back — Restart=always does that. The watchdog catches a different class: process alive, service dead (database down, pool wedged), which Restart=always cannot see. systemd's own WatchdogSec needs sd_notify, which the runtime does not speak, so the timer stands in.
The unit declares DEDALO_SUPERVISED=true — why that is mandatory.
- Verify:
systemctl cat dedalo-ts@<i>shows the template and the drop-in;systemctl is-enabled dedalo-ts@<i>isenabled. - Repair:
--resume.--abortremoves the drop-ins, and the templates when no other instance uses them.
host_selinux — SELinux on the RHEL family
By hand: Install by hand › host_selinux.
Runs only on the RHEL family with SELinux enforcing or permissive; elsewhere it does nothing. It labels what the web server reads or connects to, and the install folder's own subtrees:
| Path | Type |
|---|---|
the socket's directory (/run/dedalo-<i>(/.*)? on RHEL 10, from --socket) |
httpd_var_run_t |
/srv/dedalo/<i>(/.*)? (media and the published client), and MEDIA_PATH's web root when it lies elsewhere |
httpd_sys_content_t |
/var/lib/dedalo-install/acme/<i>(/.*)? (the Let's Encrypt challenge root) |
httpd_sys_content_t |
<dir>/dedalo(/.*)?, <dir>/private(/.*)?, <dir>/backups(/.*)?, and the folder itself when the installer created it |
var_lib_t |
| the backup directory, when it lies outside the install folder | var_lib_t |
The socket's rule is written in the spelling the host's policy accepts. The policy's path equivalences (/etc/selinux/targeted/contexts/files/file_contexts.subs_dist) map /var/run onto /run on RHEL 10, and semanage fcontext -a refuses a rule on the other side (conflicts with equivalency rule '/var/run /run').
var_lib_t lets systemd enter the working directory and read the .env wherever the folder is, while the web server is denied it. The specs escape the path (/home/my_domain\.org/dedalo/private(/.*)?), and the subtrees are relabelled as the service user. A folder the installer did not create, its parent and the account's other files (~/.ssh) are never relabelled.
It then loads one host-wide policy module, dedalo_engine (deploy/selinux/dedalo_engine.cil.tpl), at priority 400. The module gives the engine its own domain: systemd starts the pinned runtime (labelled dedalo_engine_exec_t) into dedalo_engine_t, and the web server may connect to the unix sockets of that domain only. A rule naming the generic service domain would let the web server reach every other unconfined service's socket on the host. After loading the module it relabels the runtime (restorecon -F). No SELinux boolean is changed.
- Verify:
sudo semodule -l | grep dedalo_engine;stat -c %C /opt/dedalo/bun/<pin>/buncontainsdedalo_engine_exec_t;sudo ls -Zd /srv/dedalo/<i> <dir>/dedalo <dir>/private;sudo semanage fcontext -l -C. - Repair:
--resume. A module nameddedalo_enginethat this installer did not write is refused, not replaced.--abortremoves the contexts this install added — only those the host defines (semanage fcontext -l -C); a rule you defined for the same path before the install is kept — and the module when no other instance uses it.
host_first_boot — the first start
By hand: Install by hand › host_first_boot.
Starts dedalo-ts@<i> and waits, with progress, for /health on the socket to answer 200 and for every database migration to be recorded. Under SELinux it also reads the running engine's domain and refuses a first boot whose engine is not in dedalo_engine_t (it would answer on its socket, but never through the site).
-
Verify:
systemctl status dedalo-ts@<i> --no-pager # active (running), not activating (auto-restart) sudo ls -l /run/dedalo-<i>/dedalo_ts.sock # srw-rw-rw- <service user> <service group> ls -ld /run/dedalo-<i> # drwxr-x--- <service user> <service group> — what guards the socket sudo curl --fail --unix-socket /run/dedalo-<i>/dedalo_ts.sock http://localhost/health # {"result":"ok","entity":"<entity>","db":"ok",…}The socket's directory is
0750, so only root, the service user and its group reach it: hencesudo. -
Repair: read
journalctl -u dedalo-ts@<i> -n 50, fix the cause, then--resume.--abortstops the unit.
host_watchdog — arm the watchdog
By hand: Install by hand › host_watchdog.
Enables dedalo-ts-watchdog@<i>.timer, and only now: a watchdog armed before the first boot completed would kill it mid-migration.
- Verify:
systemctl is-active dedalo-ts-watchdog@<i>.timerisactive. - Repair:
--resume.--abortdisarms it.
host_web_http — the plain-HTTP site (Let's Encrypt only)
By hand: Install by hand › host_web_http.
Writes the first stage of the site file: a plain-HTTP server with the Let's Encrypt challenge root (/var/lib/dedalo-install/acme/<i>/) and the redirect to HTTPS, so the certificate authority can prove the address before any certificate exists. The web server's user joins the service group (ded_<i> by default; it reaches the socket and reads media and client through that group). On Apache it checks the modules the site needs (ssl headers http2 rewrite proxy proxy_http) and, when one is missing, stops with the exact command to enable it; it never enables a module itself. The configuration is tested before the reload, and a reload applies every pending change of the web server's configuration, which the Review says next to the command.
- Verify:
curl -I http://<address>/answers with a redirect tohttps://. - Repair: for the Apache modules, run the command the message prints (
a2enmod … && systemctl restart apache2on Debian and Ubuntu — a restart, asa2enmoditself advises: loading a module is a process change, and a reload would also apply every other pending change), then--resume.--abortremoves the site file.
host_tls — the certificate
By hand: Install by hand › host_tls.
- Let's Encrypt:
certbot certonly --webrootagainst the challenge root, as the certificatededalo-<i>, with a deploy hook that reloads the web server, and the distribution's renewal timer enabled (certbot.timer;certbot-renew.timeron the RHEL family).--letsencrypt-staging(headless only) uses the staging authority: the install completes with a certificate browsers do not trust, and the closing To do on this host list prints thecertbotcommand that replaces it with a real one. - Your institution's files: checked again (the address, the expiry, the key, and that this server trusts the chain) and used where they are.
- Local CA:
/opt/dedalo/instances/<i>/bin/dedalo-tls-rotate.sh --mode local-ca --host <address> [--host <other address> …] --dir /opt/dedalo/instances/<i>/tls --no-reload— the same script the docker route and every rotation use. In/opt/dedalo/instances/<i>/tls/(root's; the keys0600, labelledcert_ton the RHEL family) it writes the authority staff computers trust (dedalo-local-ca.pem), the issuing authority whose key signs renewals, and the site certificate (fullchain.pem+privkey.pem, valid 397 days). The authority is restricted to the addresses it was made for; the key of the authority itself is deleted once it has signed. Then it writes the drop-in ofdedalo-tls-renew@<i>.serviceand enablesdedalo-tls-renew@<i>.timer: once a day the script checks the certificate and, in its last 30 days, re-issues it from the same authority and reloads the web server (systemctl reload nginx,apache2orhttpd). A directory that already holds an authority for these addresses is used as it is (no new authority to trust); one made for other addresses is refused on the System check.
On the RHEL family the web server must be able to read your certificate files: keep them under /etc/pki/tls or label them cert_t.
- Verify (Let's Encrypt):
certbot certificates --cert-name dedalo-<i>;systemctl is-enabled certbot.timer. - Verify (local CA):
openssl verify -CAfile /opt/dedalo/instances/<i>/tls/dedalo-local-ca.pem -untrusted /opt/dedalo/instances/<i>/tls/dedalo-issuing-ca.pem /opt/dedalo/instances/<i>/tls/fullchain.pemprintsOK;systemctl list-timers 'dedalo-tls-renew@*'; a renewal now, to see it work:systemctl start dedalo-tls-renew@<i>(it does nothing until the last 30 days) andjournalctl -u dedalo-tls-renew@<i>. - Verify (your files):
openssl x509 -noout -subject -enddate -in <fullchain>names the address and a date ahead; `openssl s_client -connect :443 -servername