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.
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. - Docker Desktop (macOS/Windows): the checkout must be inside a shared path,
or the bind mounts of
deploy/nginx.confandclient/arrive empty. Fine for evaluation; for production use Linux — see the note in the installation hub.
Check the host
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
- Ports 80 and 443 must be free. A host web server already listening there
is the single most common
docker compose upfailure. Stop it, or put Dédalo behind it (see reverse proxy). - Size the disk for the media, not for the records. The
mediavolume 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 image's entrypoint therefore
starts the server under umask 0000; 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.
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/master_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'
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:
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 mib --entity-label 'My Institution' \
--locale es-ES --timezone Europe/Madrid \
--langs lg-spa,lg-eng --app-lang lg-spa --data-lang lg-spa \
--hierarchies es,lg \
--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.--hierarchiesboth imports and activates each thesaurus. Skip it and you can install hierarchies later.
Every flag is in the installer reference. The run ends by verifying an actual root login — if it prints success, the instance is real.
This step is not repeatable
The seed restore refuses a non-empty database. If the install fails halfway,
do not re-run it against the same volumes: destroy them
(docker compose down -v — this deletes the data) and start from step 6.
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.
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.
Step 10 — Turn the media gate on
The engine wrote the rule files on its first boot. Check, then wire them in:
docker compose exec dedalo ls -l /srv/dedalo/media/dedalo_media_protection.nginx.conf \
/srv/dedalo/media/dedalo_media_protection_map.nginx.conf
Both present? Uncomment both include lines in deploy/nginx.conf (the
map one at http{} scope near the top, the media one inside the server{}),
then reload:
docker compose exec nginx nginx -t # syntax + the includes resolve
docker compose exec nginx nginx -s reload
The file is bind-mounted, so the edit is visible to the container immediately — no rebuild.
Known defect: quote the rule-B location regex
In publication mode the generated file emits one unquoted regex
containing {2,12}, which nginx's lexer truncates — nginx -t fails with
pcre2_compile() failed. It is one edit, documented in
reverse proxy. The generated file is only rewritten
when its embedded # config-hash: line stops matching, and quoting does not
change the hash, so the fix survives.
Step 11 — Prove media is actually served
Upload a file through the interface, then request it. A 404 on a file you can
see in the record means the proxy root and MEDIA_PATH disagree — the root
rule is documented at the top of deploy/nginx.conf, and the gate itself still
looks healthy when this is wrong, which is what makes it confusing.
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):
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.
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.
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.
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 |
|---|---|---|
POSTGRES_PASSWORD error before anything starts |
the variable is not exported | step 2 |
| Every request is a 502 | the proxy cannot write to the socket | the socket volume must be shared, and the engine started under umask 0000 — problem 2 |
nginx restarts forever |
missing certificate, or one include uncommented without the other |
step 4, step 10 |
nginx -t: pcre2_compile() failed |
the unquoted rule-B regex | quote it — step 10 |
| 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 |
| 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 |
B1 |
| The wizard hangs at Save config, engine down | no restart policy — the engine exits there by design | B4 |
| Every media file 404s, gate looks healthy | proxy root and MEDIA_PATH disagree |
the root rule at the top of deploy/nginx.conf |
| 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 |