Running Dédalo in containers
See also: Installation hub · Simple install · Production install · Reverse proxy and TLS · Installer reference · Troubleshooting
Want it in one command instead?
Simple install brings up a complete instance with
./install.sh, using docker-compose.simple.yml — HTTPS included, via
Let's Encrypt or a local certificate authority. The one thing it does not set
up is media access control, so it suits a collection that is public
anyway or an internal instance. This page is the full stack: the same
architecture plus the engine-enforced media gate.
The repo ships a Dockerfile, a docker-compose.yml and the reference proxy
configuration deploy/nginx.conf. Together they stand up the same architecture
the bare-metal guide builds — engine on a unix socket,
PostgreSQL behind it, a proxy in front — with four container-specific problems
solved.
This page is the whole procedure: what the files are, how to check your Docker, the install itself — by CLI or by browser wizard — and how to verify it. Read the four problems first if you plan to change anything in the compose file: they are where every container deployment of Dédalo goes wrong.
The files, and where they live
Everything is in the repo, at its root. Every command on this page runs from
the directory that holds docker-compose.yml — the master_dedalo checkout.
| Path | What it is | Do you edit it? |
|---|---|---|
Dockerfile |
the engine image: pinned Bun, psql 18, the media toolchain |
only to change the runtime pin |
docker-compose.yml |
the stack: services, volumes, ops environment | yes — it is your deployment's configuration |
.dockerignore |
the build-context policy: the whole root is denied, only the tracked entries the image needs are let back in, and secret-shaped names are denied at every depth. Generated — bun run context:gen |
no |
deploy/nginx.conf |
the reference proxy config, bind-mounted into the nginx container |
yes — domain, certificate paths, the two include lines |
deploy/certs/ |
your TLS certificate and key. Does not exist in a fresh clone — deploy/dedalo-tls-rotate.sh creates it. Never committed, and never inside the image: deploy/ is excluded from the build context, and nothing in a container reads it |
yes |
.bun-version |
the runtime pin. The Dockerfile base tag must match it |
no |
scripts/install.ts |
the headless installer, run once, inside the container | no |
client/ |
the browser client, bind-mounted read-only into nginx |
no |
There is no build step and no dependency-fetch step for the client: a clone is self-contained, and the engine runs TypeScript directly.
Two places: your host and the containers
Every path on this page lives in one of two places, and confusing them is the most common way a first install goes wrong.
| Where | Paths | How you reach them |
|---|---|---|
| The host — your checkout | docker-compose.yml, deploy/nginx.conf, deploy/certs/ |
an ordinary editor, from the checkout directory (cd …/master_dedalo) |
| Inside the containers — Docker volumes | /srv/dedalo/media, /private, /backups, /run/dedalo |
docker compose exec <service> … only |
/srv/dedalo/does not exist on the host, and it should not. Whendeploy/nginx.confsaysinclude /srv/dedalo/media/…, that is a path inside the nginx container. You only edit the line on the host; nginx resolves it in the container.- To look inside a volume, go through a container:
docker compose exec nginx ls -l /srv/dedalo/media/. The raw volume data sits under/var/lib/docker/volumes/dedalo_media/_data/(root only) — never edit it there. - Every
nginxcommand goes through the container:docker compose exec nginx nginx -t. A plainnginx -ttests the host's nginx, if it has one — a different server with a different configuration.open() "/run/nginx.pid" failed (13: Permission denied)is the tell: you are talking to the host's nginx. - Not sure where the checkout is?
docker compose lsprints the full path of every running stack's compose file.
The stack
| Service | Image | Role |
|---|---|---|
postgres |
postgres:18 |
the system of record |
dedalo |
built from Dockerfile (oven/bun:<pinned>-debian) |
the engine |
nginx |
nginx:alpine |
TCP, TLS, client statics, the media gate |
mariadb |
mariadb:11 — profile diffusion |
the publication target |
pgvector |
pgvector/pgvector:pg18 — profile rag |
the vector store |
The two optional services are behind compose profiles, so they do not start unless you ask for them:
docker compose --profile diffusion up -d # MariaDB publication target
docker compose --profile rag up -d # pgvector store
The image is not a thin Bun image. It must also carry:
- a
psqlclient that is not older than the PostgreSQL server (18) — the installer, the seed restore, the hierarchy import and the backup widget all shell out to it, and an older client refuses to connect to a newer server. The Dockerfile adds the PostgreSQL project's repository for exactly this; - the media toolchain (
ffmpeg, ImageMagick, poppler,ocrmypdf) — without it, uploads produce no derivatives and no thumbnails.
Both are why the build is slow and the image is large. That is the correct trade: an image without them installs, then fails at the first upload.
Before you start
Check your Docker
docker --version # Docker Engine
docker compose version # v2 or newer
docker info # daemon reachable? errors here = daemon not running
docker run --rm hello-world # end-to-end smoke test: pull + run
- Compose v2 or newer. Every command here is
docker compose— the Compose plugin, a subcommand ofdocker. What does not work is the legacy standalonedocker-composev1 binary: the compose file uses service profiles and health-gateddepends_on. Any major from 2 upwards is fine, so read the version as a floor, not as a match. - The daemon must be running.
docker infofailing with cannot connect to the Docker daemon means the service is stopped (systemctl start docker) or your user is not in thedockergroup. -
Run
dockerwithoutsudo.sudostarts the command with a clean environment, so the variables you export in step 2 never reach compose, and every command fails with required variable POSTGRES_PASSWORD is missing a value. Join thedockergroup once instead:sudo usermod -aG docker "$USER" newgrp docker # or log out and back in docker run --rm hello-world # now works without sudoMembership of
dockeris root-equivalent — exactly whatsudo dockeralready was. If you must keepsudo, pass the variables through on every command:sudo --preserve-env=POSTGRES_DB,POSTGRES_USER,POSTGRES_PASSWORD docker compose …. - Docker Desktop (macOS/Windows): the checkout must be inside a shared path, or the bind mounts ofdeploy/nginx.confandclient/arrive empty. Fine for evaluation; for production use Linux — see the note in the installation hub.
Check the host
sudo ss -tlnp | grep -E ':(80|443)\b' # both must be FREE — the proxy publishes them
docker system df # room for the image and the volumes?
df -h /var/lib/docker
hostname -I # this machine's address — note it for step 4
-
Ports 80 and 443 must be free. A host web server already listening there is the single most common
docker compose upfailure:failed to bind host port 0.0.0.0:80/tcp: address already in use. Thessline names the process (sudois what makes it show the name); on Ubuntu it is usuallyapache2ornginxinstalled with the system. Either:-
Stop it — the right answer when nothing else on the machine needs it:
sudo systemctl disable --now apache2 # or nginx -
Or move the stack to other ports with an override file next to
docker-compose.yml— compose readsdocker-compose.override.ymlautomatically, and you leave the shipped file untouched:docker-compose.override.ymlservices: nginx: ports: !override - "8080:80" - "8443:443"!overrideneeds Compose 2.24.4 or newer; without it the ports are added to the shipped ones and the bind still fails. Confirm withdocker compose config nginx | grep -A8 ports. Then browse tohttps://<address>:8443/dedalo/directly: the plain-HTTP port only redirects tohttps://<address>/, which drops the port. For a production machine that must keep its own web server, put Dédalo behind it instead (see reverse proxy). - Size the disk for the media, not for the records. Themediavolume holds the originals and every derivative, and it is the thing that grows. On a real deployment, back it with a volume driver or a bind mount on the large filesystem — a default named volume lives under/var/lib/docker. - Time and locale are the container's, not the host's. The installer's--timezoneis what stamps every database timestamp; set it deliberately.
-
The four container problems
1. ../private/ has no parent to live in
Dédalo keeps every secret in a private/ directory that is a sibling of the
repo. Inside an image there is no writable parent directory above the code.
Solved by DEDALO_PRIVATE_DIR=/private on a named volume. Both the
configuration read side and the installer write side honour that
variable, so the whole tree moves together — .env, the session store,
ts_state.json, the backups.
No volume, no secrets
Without a volume at /private, the .env the installer writes dies with the
container, and the next start comes up in install mode against a database
that is no longer empty — an install you cannot finish and cannot repeat.
2. The socket is invisible across containers
Production serving is socket-only, and the default socket path (/tmp/…) lives
in the container's private /tmp. The proxy container cannot see it.
Solved by relocating the socket to a shared volume:
environment:
SERVER_UNIX_SOCKET: /run/dedalo/dedalo_ts.sock
volumes:
- socket:/run/dedalo # mounted in the proxy container too
Socket permissions are the number-one cause of a 502
Connecting to a unix socket requires write permission on the socket file.
With the default umask the engine creates it owner-writable only, and the
proxy container runs as a different user. The engine therefore grants the
socket its 0666 itself, right after it starts listening; the rest of the
process runs under the image's umask 0027. The socket volume is shared
with the proxy and with nothing else.
The escape hatch: SERVER_TCP_PORT
You can set SERVER_TCP_PORT and have the proxy talk to dedalo:3600 over
the internal network instead. It works, and it costs you production parity.
If you do it: never add a ports: mapping for it. A published TCP port
bypasses the proxy — and with it, TLS and the entire media access gate.
3. The engine writes the media rules; the proxy reads them
Media access control is enforced by the web server, using rule files the
engine generates into MEDIA_PATH. So both containers must mount the same
media volume:
dedalo:
volumes: [ media:/srv/dedalo/media ] # writes the rules + the marker store
nginx:
volumes: [ media:/srv/dedalo/media:ro ] # reads them, and serves the bytes
The gate is wired in two moves, and the shipped config is the safe one
deploy/nginx.conf needs two includes of generated files — one at http{}
scope (the cookie-sanitising map), one inside the media server{}. Those
files do not exist until the engine has booted with a media access mode
set, which is why the compose file sets
DEDALO_MEDIA_ACCESS_MODE=publication by default.
The config therefore ships with both lines commented out: nginx starts,
and /dedalo/media/ simply 404s — media is not served at all, which is the
safe failure. You uncomment both after the engine's first boot (step
10). Both or neither: the server-scope
file uses a variable the map defines, so including one without the other
makes nginx refuse to start.
A media mode of unset means "no gate": the engine writes no rules, the includes stay commented forever, and you are serving your media tree to the world by decision rather than by accident.
The proxy reads media through the engine's GROUP
The engine runs as bun (uid/gid 1000) under umask 0027, so every media
file it writes is 640 inside 750 directories: owner and group may read,
the world may not. nginx's workers run as nginx (uid 101), so the shipped
stacks put that user into a gid-1000 group inside the nginx container,
before nginx starts — the addgroup calls at the head of the service's
command:. Keep them if you replace the command.
group_add: ["1000"] does not do this: it reaches only the root master
process, and each worker rebuilds its groups from /etc/group when it drops
to nginx. And never "fix" a 403 with chmod o+r: it makes media
world-readable and the next upload is 403 again. This is the same model as
the bare-metal unit (UMask=0007 plus the web-server user in the dedalo
group).
4. Installing: one shot, or the wizard
Two front ends drive the same install engine. Pick one before you bring the
stack up, because they diverge at the very first docker compose up.
| Path A — the one-shot CLI | Path B — the browser wizard | |
|---|---|---|
| Install runs in | a throwaway docker compose run container |
the long-lived dedalo service |
| Restart mid-install | none | yes — the engine exits after Save config |
| Pre-auth window on the network | never opened | open until you press Finish |
| Good for | servers, orchestration, anything repeatable | a first look, or when you want the diagnostics panel |
Path A is the recommendation for anything reachable from a network. It never
serves the install surface at all. Path B is the same engine with a UI in front,
and it needs one extra precaution — the pre-auth window — plus a supervisor,
which in compose is restart: unless-stopped.
Path A — install with the one-shot CLI
Start to finish. Steps 1–11 are the install; step 12 is the first login.
Step 1 — Get the source
git clone <your-dedalo-remote> dedalo
cd dedalo
ls docker-compose.yml Dockerfile deploy/nginx.conf # you are in the right place
Step 2 — Choose the database credentials
The postgres service reads three variables. POSTGRES_PASSWORD has no default
and compose refuses to start without it — that is deliberate.
export POSTGRES_DB=dedalo_main
export POSTGRES_USER=dedalo_user
export POSTGRES_PASSWORD='a-long-random-password'
An export lives only in this shell. A new terminal, an SSH reconnect or
sudo (see Check your Docker) starts without them, and
compose then refuses with required variable POSTGRES_PASSWORD is missing a
value. Check before every session with echo "$POSTGRES_PASSWORD".
POSTGRES_DB and POSTGRES_USER are the dangerous pair: they have defaults,
so when they go missing nothing fails — you silently get a database with a
different name and owner from the ones the installer was told.
Do not create a .env at the repo root
Compose would read it for variable substitution — but so would the engine's
own configuration loader, from the container's working directory. Export the
variables in your shell, or keep them in a file elsewhere and pass
docker compose --env-file <somewhere-else> … to every command below.
Step 3 — Set your domain and the ops keys
Two edits, both in files you own:
deploy/nginx.conf— replacededalo.example.orgin the twoserver_namelines. Leave it in the two certificate paths for now, or change the compose file's./deploy/certs:/etc/letsencrypt/live/dedalo.example.org:romount to match — the path in the config and the mount target must agree.docker-compose.yml— thededaloservice'senvironment:block is your ops surface: pool sizes, timeouts, the access log, the media mode. LeaveDEDALO_PRIVATE_DIR,SERVER_UNIX_SOCKETandMEDIA_PATHalone unless you have read the four problems.
Step 4 — Provide a TLS certificate
nginx will not start without one, and neither will a login work over plain HTTP
(see TLS). One script issues every certificate this stack uses,
deploy/dedalo-tls-rotate.sh — the same one ./install.sh calls, and the same
one you will run to rotate. Do not hand-roll a pair with openssl: two
generators drift in SAN, lifetime and file permissions the first time one of
them is fixed, and the hand-rolled one has no archive step, so a re-run
overwrites the material you may still need.
For a LAN or a trial, issue a local certificate authority. Use the name or IP
staff will actually type in the browser — a certificate whose SAN does not match
is rejected outright. On a virtual machine that is the VM's own address
(hostname -I inside it), not localhost:
deploy/dedalo-tls-rotate.sh --mode local-ca --host dedalo.example.org --no-reload
--no-reload because the stack is not up yet. It writes
deploy/certs/{privkey.pem,fullchain.pem} plus the authority
dedalo-local-ca.{key,pem}, sets the keys to 600, and prints the CA file with
its SHA-256 fingerprint — that file is the one you install on every staff
computer.
If your institution already issues certificates, install them instead of generating one:
deploy/dedalo-tls-rotate.sh --mode existing \
--cert /path/fullchain.pem --key /path/privkey.pem --no-reload
For a public domain, bind-mount your certbot tree instead — TLS below.
Never commit or copy key material into the repo tree
deploy/certs/ is ignored by git and excluded from the container build
context, so the key you just created cannot reach an image, a release
archive or a push. That containment is mechanical, and it only holds for
material inside deploy/certs/. A key parked somewhere else in the
checkout is a different matter: it can travel. If one already has,
rotate it.
Step 5 — Build the image
docker compose build
Slow the first time: the media toolchain and the PostgreSQL client are a large apt transaction. Re-builds after a source change reuse the dependency layer.
The Dockerfile is multi-stage and this command builds production — the
default, production dependencies only. There is one other target, dev, which
is that same image with the devDependencies put back (the browser test harness
and the LESS compiler); it exists for a development box and is not part of any
install path. docker compose build takes no --target flag: the target comes
from the compose file (a dev overlay sets it), or from a plain
docker build --target dev .. That overlay also sets DEDALO_DEV_MODE=true, and
the test harness needs BOTH — the serving guard refuses a dev-only library
outside dev mode however present its files are.
Check the runtime pin if the build behaves oddly
cat .bun-version
grep '^FROM' Dockerfile
Step 6 — Start the database alone
docker compose up -d postgres
docker compose ps # wait for postgres → healthy
This creates an empty database owned by the role, which is exactly the installer's precondition: it restores into a database, refuses a non-empty one, and never creates one itself. The role owning the database is also what lets the seed create its extensions.
Step 7 — Run the installer, once
docker compose run --rm \
-e DEDALO_INSTALL_ROOT_PASSWORD='the-root-password' dedalo \
bun run scripts/install.ts \
--db-name "$POSTGRES_DB" --db-user "$POSTGRES_USER" \
--db-password "$POSTGRES_PASSWORD" --db-host postgres \
--entity myentity --entity-label 'My Institution' \
--locale es-ES --timezone Europe/Madrid \
--langs lg-eng,lg-spa --app-lang lg-eng --data-lang lg-eng \
--media-path /srv/dedalo/media \
--socket /run/dedalo/dedalo_ts.sock \
--media-access-mode publication
What each part is doing:
run --rmruns a one-off container from thededaloservice definition, so it inherits the same volumes (/private, the media tree) and the same environment. The install therefore lands on the volumes the long-lived service will use, and the container is discarded.--db-host postgresis the compose service name — the engine reaches the database over the internal network, not over a published port.- The root password goes in the environment, never in argv: an argv is
visible in
psand lands in your shell history. --media-path,--socketand--media-access-modeare persisted into/private/.env, so the file describes the deployment on its own. The compose environment sets the same three at runtime and wins either way — passing them keeps the two in agreement.--langsis the installation's languages — the interface languages users can switch to and the tabs every translatable field gets — and--app-langand--data-langmust be among them. The values shown are the defaults (English + Spanish, English for both), so the three flags can be left out.- No
--hierarchies: the shared default set of optional thesauri (today Spain,es) is imported and activated.--hierarchies noneskips it, a list such as--hierarchies es,frreplaces it, and you can always install hierarchies later. The Languages thesaurus (lg) is not on that list: it is activated together with the database on every install. - The installer writes
ONTOLOGY_SERVERSandCODE_SERVERSnaming the official Dédalo update server — where ontology updates and release information come from. Add--no-update-serversfor an air-gapped instance (both written as[]). On this stack a new code release still arrives as a new image, as described in upgrading; that is unchanged.
Every flag is in the installer reference. The run ends by verifying an actual root login — if it prints success, the instance is real.
The installer prints one → line per step. Where it stopped decides what you
do next:
- Before
→ restore database from seed(pre-flight, database connection, write.env, directories): nothing is in the database yet. Fix the cause and run the same command again — the.envit already wrote is reused, and the secrets it generated are kept. - At or after
→ restore database from seed: see the danger box below.
Past the seed restore, this step is not repeatable
The seed restore refuses a non-empty database. If the install fails at or
after it, do not re-run it against the same volumes: destroy them
(docker compose down -v — this deletes the data) and start from step 6.
install failed at → directories, naming /backups/db
The backups volume is owned by root, so the installer (user bun) cannot
write to it. Images built before 2026-10-08 created that volume root-owned.
Rebuild from a current checkout (docker compose build) and run step 7
again: Docker gives an empty volume the ownership of the image's directory,
so the rebuild heals it. Without a rebuild, one command does the same:
docker compose run --rm --no-deps --user root dedalo chown -R bun:bun /backups.
Step 8 — Start the whole stack
docker compose up -d
docker compose ps
postgres and dedalo should report healthy. nginx has no healthcheck —
check it with docker compose logs nginx, and read its PORTS column: it
must show 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp (or your override's
ports). An empty PORTS column means nothing is published and no browser can
reach the stack — usually a broken docker-compose.override.yml. Fix it, then
docker compose up -d --force-recreate nginx.
The engine's healthcheck also acts. It is not a bare curl but
scripts/ops/container_watchdog.sh, which probes /health over the socket,
reports unhealthy to docker compose ps exactly as a bare probe would, and
after three consecutive red probes 30 seconds apart sends SIGTERM to the
engine so it drains and restart: unless-stopped recycles the container. That is
the consumer of the 503 the engine emits when its process is poisoned, its pool
is wedged or the database is gone — Docker Engine itself never restarts an
unhealthy container. It escalates only after the container has answered green at
least once, so an instance still sitting on the browser wizard (no database yet,
so /health is red) is left alone. Budget roughly 90 seconds for a recycle here,
not the 30 seconds of the systemd deployment.
Step 9 — Confirm the engine answers
docker compose exec dedalo curl --fail --unix-socket /run/dedalo/dedalo_ts.sock \
http://localhost/health # {"result":"ok","db":"ok"}
curl -k -I https://localhost/dedalo/core/page/ # 200 through the proxy
A 502 here is almost always the socket permissions (problem 2); a connection refused is nginx crash-looping — read its log.
Then from the computer you will actually work on. Open
https://<address>/dedalo/ — always https://, always the /dedalo/ path.
Plain HTTP only redirects, and a login over it cannot work (TLS). The
address depends on where Docker runs:
| Docker runs on | <address> is |
|---|---|
| a server, or a VM with a bridged network | the machine's own IP — hostname -I on it — or its DNS name |
| a VM with a NAT network (the VirtualBox / UTM / Parallels default) | localhost:<port>, after you forward a host port to the VM's 443 in the hypervisor's network settings |
| Docker Desktop on your own computer | localhost |
192.168.65.x is Docker Desktop's internal network, not an address of your
machine: a browser pointed at it is refused. If the curl above answers inside the
machine but your browser cannot connect, the problem is the network path to the
machine (or the PORTS column above), not Dédalo.
With the local certificate authority from step 4, the browser warns until you
install dedalo-local-ca.pem on that computer.
Step 10 — Turn the media gate on
Until this step, no media file is served: the shipped deploy/nginx.conf
has the gate's two include lines commented out, so every /dedalo/media/…
request is a 404 — uploads work, the files are on disk, and the browser still
shows nothing. That is the safe failure (problem
3); this step ends
it.
1. Check that the engine wrote the rule files on its first boot:
docker compose exec dedalo ls -l /srv/dedalo/media/dedalo_media_protection.nginx.conf \
/srv/dedalo/media/dedalo_media_protection_map.nginx.conf
Both must be there. (These paths are inside the containers — see two
places. On the host you edit only
deploy/nginx.conf.)
2. Uncomment both include lines in deploy/nginx.conf, on the host, from
the checkout directory. Both or neither — the server-scope file uses a variable
the map file defines. One command does both (on macOS, write sed -i ''):
sed -i -E 's|^([[:space:]]*)#[[:space:]]*(include /srv/dedalo/media/dedalo_media_protection(_map)?\.nginx\.conf;)|\1\2|' deploy/nginx.conf
Or by hand (nano deploy/nginx.conf, search with Ctrl+W for
dedalo_media_protection): delete the leading # from the line near the top
(…_map.nginx.conf, http{} scope) and from the one inside server{}. Leave
the commented # location /dedalo/media/ { block alone — that is the
unprotected alternative.
Check the result — both lines, no #:
grep -n 'include /srv/dedalo/media' deploy/nginx.conf
34:include /srv/dedalo/media/dedalo_media_protection_map.nginx.conf;
103: include /srv/dedalo/media/dedalo_media_protection.nginx.conf;
3. Recreate the proxy — recreate, not reload:
docker compose up -d --force-recreate nginx
docker compose exec nginx nginx -t
Why reload is not enough after editing deploy/nginx.conf
The file is bind-mounted as a single file, and Docker pins a single-file
mount to the file's inode. nano, vim and sed -i all save by writing a
new file and renaming it over the old one — a new inode — so the container
keeps the old text, and nginx -s reload re-reads the old text.
Recreating the container mounts the current file. This applies to every
later edit of deploy/nginx.conf, not only this one.
4. Confirm the running nginx has the gate:
docker compose exec nginx nginx -T 2>/dev/null \
| grep -n -E 'dedalo_media_protection|location .*/dedalo/media'
The two include lines must appear without #, followed by several
generated location … /dedalo/media… blocks. A # in front of them means the
container is still serving the old file: go back to 3.
Step 11 — Prove media is actually served
Log in, upload a file to a record, and open it. A 404 has four possible
causes, because the gate answers 404 both for a file that does not exist and
for a request it refuses. Check them in this order:
- The gate is not loaded — step 10.4 shows commented
includelines. -
The file does not exist yet. The upload stores the
original; the derivatives (av/404/…, the 404-pixel video the player asks for, and the image thumbnails) are produced afterwards by a background job. Compare:docker compose exec nginx ls -l /srv/dedalo/media/av/original/ /srv/dedalo/media/av/404/The original present and the derivative missing is a transcoding problem — read
docker compose logs dedalo, not the proxy. 3. The browser has no media cookie. Access is granted by thededalo_media_authcookie, set at login, matching a file in/srv/dedalo/media/.publication/auth/. A session opened before the gate was on, or in another browser, does not carry it: log out and log in again. 4. The proxyrootandMEDIA_PATHdisagree — the root rule documented at the top ofdeploy/nginx.conf. The gate looks perfectly healthy when this is wrong, which is what makes it confusing.
A 403 is a different problem: nginx's workers are not in the engine's group (problem 3).
Step 12 — Log in and seal the deployment
- Open
https://<your-domain>/dedalo/core/page/and log in asroot. - Create an admin user; keep
rootfor emergencies. - Continue with after the install: users and projects, hierarchies, and backups.
Path B — install with the browser wizard
Same engine, driven from a browser. It replaces Path A's steps 6–7 only: steps 1–5 are identical (source, credentials, domain, TLS, build), and once the wizard says Finish you rejoin Path A at step 10.
The wizard's own screens — Diagnostics, Database, Entity, Diffusion, Outbound email, Save config, Verify, Directories, Install database, Root password, Hierarchies, Tools, Finish — are documented once, in the installer reference. What follows is only what containers change.
B1 — Name the address you will install from
A fresh instance has no users, so every install action is reachable without a
login until you press Finish. Path A never opens that window; Path B does,
and docker compose up publishes ports 80 and 443 in the same breath.
The engine closes that window for you: with DEDALO_INSTALL_ALLOWED_IPS unset,
the wizard answers the local machine and nobody else. In a container that is
nobody at all — nginx forwards your browser's real address, which is never the
loopback of the engine's namespace — so Path B does not work until you say who
you are. Set it in the dedalo service's environment: before the stack
ever comes up (the shipped compose file already passes the variable through, so
exporting it is enough):
In the same shell you will run docker compose up -d from (B2), replacing
203.0.113.10 with the address you will browse from:
export DEDALO_INSTALL_ALLOWED_IPS=203.0.113.10
Check that compose picked it up before going on:
docker compose config | grep DEDALO_INSTALL_ALLOWED_IPS
The export lives only in that shell: a new terminal needs it again. To set it
permanently instead, edit the dedalo service's environment: in the compose
file:
environment:
DEDALO_INSTALL_ALLOWED_IPS: "203.0.113.10" # the address YOU will browse from
A value is a comma list of four possible things: loopback, a literal address,
a range such as 10.0.0.0/24, or any. If the wizard refuses you anyway, its
message names the address the engine saw — add that one. Behind the nginx
container it is rarely the address you would guess (Docker Desktop: 192.168.65.1;
a Linux bridge: a gateway such as 172.18.0.1).
loopback will not match behind the proxy
The address is resolved from the trusted X-Forwarded-For hop — and the
compose file sets TRUSTED_PROXY_HOPS: "1" for exactly this — so behind the
nginx container the caller is never the loopback address. Naming loopback
locks you out while leaving nobody else out. Name the real client
address, or the range your workstations sit in.
any opens the surface to every address. It is the one spelling that does
that, it is never a default, and it is the wrong choice for anything with a
public port — use it only when a firewall already stands in front, and drop
it the moment the instance is sealed.
B2 — Bring the stack up on an empty private volume
Install mode is not a flag; it is the absence of /private/.env. So this
only works on a first run, or after you have destroyed the volume.
docker compose up -d
docker compose logs dedalo | grep 'INSTALL MODE'
You want to see the engine announce it:
[boot] INSTALL MODE — no database configured yet (../private/.env absent).
Serving the install wizard at /dedalo/core/page/.
No such line means .env already exists and you are looking at a normal boot —
the wizard will not appear. Check with
docker compose exec dedalo cat /private/.env.
In install mode the engine skips every database-dependent boot step, so
postgres starting healthy is all the database needs to do at this point.
B3 — Run the wizard, with the container's answers
Open https://<your-domain>/dedalo/core/page/. Three fields are
container-specific:
| Wizard field | What to enter | Why |
|---|---|---|
| Database host | postgres |
the compose service name. localhost is the engine's own container and there is no database in it |
| Database name / user / password | your POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD |
the postgres service already created exactly this, empty and owned by the role |
| Port | 5432 |
the internal network port — it is not published, and does not need to be |
The compose environment overrides what you type
Process environment wins over /private/.env. The compose file already sets
MEDIA_PATH, SERVER_UNIX_SOCKET and DEDALO_MEDIA_ACCESS_MODE, so
whatever the wizard writes for those three, the compose values are what the
engine runs with. Change them in docker-compose.yml, not in the wizard —
otherwise .env and the running configuration disagree, which is a debugging
trap rather than a failure.
B4 — Survive the restart at Save config
Configuration is read once, at boot. So Save config writes .env and then
exits the process — deliberately, so it can come back with the real
configuration. restart: unless-stopped is what brings it back; it is the
compose equivalent of systemd's Restart=always, and it restarts on the planned
exit code the same way it restarts on a crash.
docker compose logs -f dedalo # watch it exit and come straight back
Leave the browser tab open. The wizard survives the restart: the Verify
button retries, and even a full page reload resumes the wizard rather than
dropping to a login form. The state that makes this work is install_status in
/private/ts_state.json.
A container that does not come back was never supervised
If you removed restart: unless-stopped, the engine exits at Save config
and stays down — the install hangs there with .env written and nothing
serving. Restore the policy and docker compose up -d; the wizard resumes.
B5 — Finish, and confirm the surface is sealed
Work through Verify → Directories → Install database → Root password → log in → Hierarchies → Tools → Finish. Finish is refused unless a root user with a password actually exists, so a half-built instance cannot be sealed.
Once sealed, the whole install surface answers 404 permanently, and
DEDALO_INSTALL_ALLOWED_IPS has no further job — drop it (and if you wrote
any, drop it now rather than later).
docker compose exec dedalo cat /private/ts_state.json # install_status: "sealed"
B6 — Rejoin Path A
The engine has now booted with a media access mode and written its rule files.
Continue at step 10 — turn the media gate on,
then step 11. Media is not served
until you do: the include lines are still commented out.
Configuration
Process environment wins over /private/.env, so the compose file is the right
place for operations keys (pool sizes, timeouts, the access log, the media
mode) and the installer owns the rest inside the volume. The full key catalogue
is the configuration reference.
Both compose stacks declare DEDALO_SUPERVISED: \"true\"
restart: unless-stopped is what brings the engine back after a planned
exit, so the dedalo service's environment: says so with
DEDALO_SUPERVISED: "true" — in docker-compose.yml and in
docker-compose.simple.yml. Keep it if you write your own stack. It belongs in
the compose file, never in /private/.env: the engine reads this key from the
process environment only and ignores it in .env. On an image install the
code update panel still refuses to
swap the code tree in place — a new release arrives as a new image, as
described in upgrading.
To read what the installer actually wrote:
docker compose exec dedalo cat /private/.env
TLS
deploy/nginx.conf expects a certificate at
/etc/letsencrypt/live/dedalo.example.org/. The compose file bind-mounts
./deploy/certs there.
- Real deployment: bind-mount your certbot tree instead, and change the
server_nameand the certificate paths indeploy/nginx.conf. Renewal happens on the host; reload the proxy afterwards (docker compose exec nginx nginx -s reload). - Local trial: the local authority from step 4.
TLS is not optional even locally: SESSION_COOKIE_SECURE defaults to true, so
over plain HTTP the browser discards the session cookie and nobody can log in.
Rotating TLS material
Same script, same arguments — rotation is not a different procedure from the first issue, which is exactly why there is only one generator:
# a new local authority and a new site certificate
deploy/dedalo-tls-rotate.sh --mode local-ca --host dedalo.example.org \
--compose-file docker-compose.yml
# a replacement certificate from your institution
deploy/dedalo-tls-rotate.sh --mode existing \
--cert /path/fullchain.pem --key /path/privkey.pem \
--compose-file docker-compose.yml
Pass --compose-file docker-compose.yml on this page's full stack (the script
defaults to the simple stack) so it can reload the proxy for you; it tells you
when it could not, and docker compose exec nginx nginx -s reload finishes the
job. The full stack's proxy does not reload itself, so until you reload it,
it keeps serving the old certificate.
What the script does, and why each part matters:
- It archives first. Everything in
deploy/certs/moves todeploy/certs/rotated-<UTC stamp>/(mode700, never overwritten) before anything new is written, so a rotation interrupted halfway can be put back. - Each authority carries its issue stamp in its subject
(
CN=Dedalo local CA <stamp>). Two entries both called "Dedalo local CA" are indistinguishable in a Windows or macOS trust store, and rotating away from a compromised authority means being able to say which entry to delete. - Install the new CA on every computer, then delete the old entry. Until the old one is gone, a certificate signed with the old key is still trusted there — which is the whole point of rotating. The script prints the per-platform steps.
An image built before 2026-08-28 may contain your CA private key
Until then the image copied the whole build context, and ./install.sh
writes deploy/certs/ before it builds. Any image built on such a host
carries dedalo-local-ca.key — the private key of an authority you were told
to install into the Trusted Root store of every computer that uses Dédalo.
Whoever holds it can mint a browser-trusted certificate for any hostname, on
all of those machines, and it travels wherever the image travels: a registry,
a docker save tarball, a copy handed to a supplier.
Treat it as compromised — it cannot be un-distributed. Rotate as above,
install the new CA everywhere and delete the old entry, rotate
POSTGRES_PASSWORD in .dedalo.env, then rebuild and re-distribute the
image from a current checkout. Confirm the rebuild is clean:
docker run --rm --user 0 <image> ls /opt/dedalo/master_dedalo/deploy
# expected: "No such file or directory" — deploy/ is not in the image at all
The full operator procedure, including the database-password step, is
§13 of engineering/PRODUCTION.md in the repo.
Day-to-day operation
docker compose logs -f dedalo # follow the engine
docker compose restart dedalo # after an ops env change
docker compose exec dedalo bash # a shell in the engine container
docker compose stop # stop, keep the data
docker compose down # remove containers, KEEP the volumes
docker compose down -v # remove the volumes too — DESTROYS the instance
A configuration change in docker-compose.yml needs docker compose up -d
(recreate), not restart — restart reuses the existing container and its
old environment. An edit of deploy/nginx.conf needs
docker compose up -d --force-recreate nginx: a reload can keep serving the
old file (see step 10).
Backups from a container
The four stores do not change; only the way you reach
them does. Volume names are prefixed with the compose project name (dedalo, set
by name: in the compose file) — confirm with docker volume ls.
# 1. The matrix database.
docker compose exec -T postgres \
pg_dump -F c -b -U "$POSTGRES_USER" "$POSTGRES_DB" > backup_$(date +%F).custom
# 2. The RAG vector database (profile `rag`), if enabled — same shape.
# 3. The media ORIGINALS. The `original` quality is the source of truth every
# derivative is rebuilt from; derivatives need no backup.
docker run --rm -v dedalo_media:/media -v "$PWD:/out" alpine \
tar czf /out/media_$(date +%F).tgz -C /media .
# 4. The private volume — .env secrets, session store, ts_state.json. Small, and
# without it a restored database is an instance you cannot start.
docker run --rm -v dedalo_private:/private -v "$PWD:/out" alpine \
tar czf /out/private_$(date +%F).tgz -C /private .
A backup that has never been restored is a hypothesis
Restore-test into a scratch stack at least quarterly.
Upgrading
git pull
docker compose build
docker compose up -d
- Boot migrations run automatically when the engine starts. There is no separate migrate step.
- The seed is never re-applied. The restore refuses a non-empty database, and after the first install the database is not empty. An upgrade cannot clobber your data by re-running the installer.
- Check the runtime pin. The
Dockerfile's base tag and the repo's.bun-versionmust agree; the engine warns loudly at boot when they do not.
The full upgrade procedure, including rollback, is in upgrading.
Verify
docker compose ps # every service healthy
docker compose exec dedalo curl --fail --unix-socket /run/dedalo/dedalo_ts.sock \
http://localhost/health # {"result":"ok","db":"ok"}
curl -k -I https://localhost/dedalo/core/page/
docker compose logs -f dedalo
When it does not work
Container-specific symptoms; everything else is in troubleshooting.
| Symptom | Cause | Fix |
|---|---|---|
required variable POSTGRES_PASSWORD is missing a value |
the variable is not exported in this shell — or you ran sudo docker …, which drops it |
step 2, check your Docker |
failed to bind host port 0.0.0.0:80/tcp: address already in use |
a web server on the host already holds port 80 or 443 | check the host |
install failed at → directories, naming /backups/db |
the backups volume is root-owned (images built before 2026-10-08) |
step 7 |
The browser cannot connect at all; docker compose ps shows an empty PORTS column for nginx |
nothing is published — usually a broken docker-compose.override.yml |
step 8 |
| The browser cannot connect, PORTS look right | the wrong address (192.168.65.x is Docker Desktop's internal network), or a NAT VM without port forwarding |
step 9 |
nginx -t: open() "/run/nginx.pid" failed (13: Permission denied) |
you ran the host's nginx, not the container's | docker compose exec nginx nginx -t — two places |
| Uploads work but every media file is a 404 | the gate is not on: the include lines are still commented — in the file, or only in the running container after a reload |
step 10, then step 11 |
| Every request is a 502 | the proxy cannot write to the socket | the socket volume must be shared, and the engine must grant the socket 0666 itself — problem 2 |
nginx restarts forever |
missing certificate, or one include uncommented without the other |
step 4, step 10 |
nginx -t: pcre2_compile() failed |
a rule file generated before 2026-07-12 left the rule-B regex unquoted | quote it — reverse proxy |
| The wizard appears after a successful install | /private is not on a volume, so .env was lost |
problem 1 |
| The wizard never appears — normal login instead | /private/.env already exists, so the engine is not in install mode |
B2 |
| Newly uploaded media is a 403, older media serves | nginx's workers are not in the engine's group | keep the addgroup calls in nginx's command: — problem 3 |
| The install surface 403s from your browser | the key is unset (the default is the local machine only), your address is not in DEDALO_INSTALL_ALLOWED_IPS, or you named loopback behind the proxy. The refusal names the address the engine saw — add that one |
B1 |
| The wizard hangs at Save config, engine down | no restart policy — the engine exits there by design | B4 |
| Every media file 404s, gate loaded | the derivative is not produced yet, the browser has no media cookie, or proxy root and MEDIA_PATH disagree |
step 11 |
| Uploads fail with 413 | client_max_body_size |
already 300m in the shipped config — check you did not replace it |
| Login "succeeds" but bounces back to the form | plain HTTP, and SESSION_COOKIE_SECURE is on |
TLS |