Skip to content

Troubleshooting an install

See also: Production install · Reverse proxy and TLS · Installer reference · Dev quickstart

Symptom first. Each entry names the cause and the fix. If your symptom is not here, start with the two things that answer most questions:

journalctl -u dedalo-ts -n 100 -o cat            # the engine's own words
curl --fail --unix-socket /run/dedalo/dedalo_ts.sock http://localhost/health
Symptom Jump to
The installer stops on a psql complaint Installing
Database is not empty … restore refused Installing
The server refuses to boot Booting
Cannot find package … on start Booting
The socket is not where the unit says Booting
The server serves the install wizard instead of the app Booting
The wizard resumes on an install that is already finished Booting
The wizard is a 404, or refuses your address Booting
502 from the proxy Serving
Apache: AH01144, or AH01630 naming a path under /etc/apache2 Serving
A client asset 404s at a path that is not in the repo Serving
nginx will not start Serving
A big export dies after about a minute Serving
Uploads fail with 413 Serving
The maintenance widget says the engine is down, but the socket probe is green Serving
Nobody can log in, and there is no error Using it
Media 404s Media
Media is served to everyone Media
MEDIA_HTACCESS_ADDONS rules do not appear Media
Uploads produce no thumbnail Media

Installing

PostgreSQL client (psql) not found

Cause. The pre-flight check could not resolve a psql binary. The installer, the seed restore, the hierarchy import and the backup widget all shell out to it, so this is a hard gate.

Fix. Install the client package (postgresql-client-18), or point at it:

DEDALO_PG_BIN_PATH=/usr/lib/postgresql/18/bin

The install fails part way through, with a connection error

Cause. Very often: psql is older than the server. An older client refuses to connect to a newer server, and a machine with several major versions installed can easily resolve the wrong one.

Fix. Check both, then pin the client with DEDALO_PG_BIN_PATH:

psql --version
sudo -u postgres psql -c 'SHOW server_version;'

Database is not empty (matrix_users already exists) — restore refused

Cause. Exactly what it says. The installer restores into a database and never clobbers an existing install. It probes for the matrix_users table.

Fix. Point at an empty database, or drop and re-create this one — but be certain first, because a populated matrix_users means somebody already installed here.

DROP DATABASE dedalo_main;
CREATE DATABASE dedalo_main WITH ENCODING='UTF8' OWNER=dedalo_user;

Private config directory is not creatable/writable

Cause. The installer creates ../private/ — a sibling of the repo. The directory one level above the repo is not writable by the service user.

Fix. chown dedalo:dedalo /opt/dedalo (the parent, not the repo). In a container, this is what DEDALO_PRIVATE_DIR is for — see Docker.

Booting

Missing required config key 'DB_NAME' (or ENTITY, DB_HOST, DB_USER)

Cause. A partial configuration. The four keys ENTITY, DB_NAME, DB_HOST, DB_USER are all-or-nothing: with none of them set, the server boots into install mode; with some of them set, it is misconfigured and says so. This is deliberate — a half-configured server must not quietly fall back to install mode against a real database.

Fix. Set all four (they are written by the installer, under their DEDALO_*_CONN spellings), or none.

The server serves the install wizard instead of the application

Cause. It booted with none of those four keys set, so it is in install mode. Almost always: it is not reading the .env you think it is.

Fix. The file must be at ../private/.env, one level above the repo — or wherever DEDALO_PRIVATE_DIR points. Confirm:

sudo -u dedalo cat /opt/dedalo/private/.env | head -5
journalctl -u dedalo-ts | grep 'INSTALL MODE'

If the four keys are set and being read, this is not install mode — see the next entry.

The wizard resumes on an install that is already finished

Cause. The install was never sealed. install_finish is what writes install_status: 'sealed'; until it runs the status stays configured, and a configured instance deliberately re-mounts the wizard on every reload rather than dropping to a login form (that is what lets a mid-install reload resume instead of stranding you on a login with no schema and no root user). Abandon the wizard one step before the end — close the tab, restart the server — and it resumes forever, even though the database is fully built.

Recognise it by the state file plus a configured .env:

grep install_status ../private/ts_state.json     # "configured", not "sealed"

Fix. Reopen the wizard and let it run to Finish, which calls install_finish. It refuses to seal unless the root user exists with a password set, so it will tell you if a step really is outstanding. To seal from the shell instead — same guard, same result — run from the repo root:

bun -e "import {installFinish} from './src/core/install/finish.ts'; console.log(await installFinish())"

The seal applies immediately (the state file is read fresh on every request; no restart), but reload the browser page: the wizard is already mounted in the open page's JavaScript and only goes away when the client calls start again.

Config key 'DEDALO_PREFIX_TIPOS' is RETIRED

Cause. A retired key is not an alias: it configures nothing. Left in place it would silently fall back to the replacement key's default, so the server refuses to boot instead.

Fix. Rename the line in ../private/.env:

ACTIVE_ONTOLOGY_TLDS=dd,rsc,oh,ich,lg,hierarchy

Crash loop right after the wizard's Save config

Cause. The language configuration is mandatory and the written .env is missing it, so every boot dies on:

Config key 'DEDALO_APPLICATION_LANGS' must be a non-empty JSON object map

The wizard collects the languages on the Entity step; a hand-written .env has to carry them itself.

Fix. Ensure all four keys are present:

DEDALO_APPLICATION_LANGS={"lg-spa":"Castellano","lg-eng":"English"}
DEDALO_PROJECTS_DEFAULT_LANGS=["lg-spa","lg-eng"]
DEDALO_APPLICATION_LANGS_DEFAULT=lg-spa
DEDALO_DATA_LANG_DEFAULT=lg-spa

FATAL: another server instance is already listening on … — refusing to steal its socket

Cause. The double-start guard. A pre-existing socket file is probed with a connection; if something answers, a second instance would silently orphan the first, so it exits 1 instead.

Fix. Stop the running instance, or point SERVER_UNIX_SOCKET elsewhere. A stale socket file (nothing listening) is removed automatically — this error only fires when something really is alive on it.

error: Cannot find package 'zod' from '…/src/core/concepts/rqo.ts'

Cause. That clone has no usable node_modules. The package name varies; the path in the message is the authoritative part — it names the tree the engine is running from, which is not necessarily the tree you installed into.

Almost always one of two things:

  • bun install was never run in that directory, or was run in a different one. sudo -u <user> <cmd> does not move to that user's home; it inherits the directory you are standing in, so an install launched from /root reports could not find a package.json file to install from and touches nothing.
  • It was run as root, leaving a node_modules the service user cannot read.

Fix. Install as the service user, in the clone, in one command:

sudo -u dedalo bash -c 'cd /opt/dedalo/master_dedalo && /opt/dedalo/.bun/bin/bun install --frozen-lockfile --production'
ls -d /opt/dedalo/master_dedalo/node_modules/zod      # proof it landed

Then clear the crash-loop trip before starting — Restart=always does not:

sudo systemctl reset-failed dedalo-ts && sudo systemctl start dedalo-ts

The engine listens on /tmp/dedalo_ts.sock instead of /run/dedalo/…

Cause. SERVER_UNIX_SOCKET is not set in ../private/.env, so the engine falls back to its default path and ignores the unit's RuntimeDirectory entirely. The boot line says exactly where it bound:

Dédalo TS server listening on unix socket /tmp/dedalo_ts.sock (entity: …)

Two consequences, both delayed: the proxy points at a socket that never appears and every request 502s, and a second install on the same host refuses to boot, because something is already answering on the default path.

Fix. Set it per install, matching the unit's RuntimeDirectory, and restart:

SERVER_UNIX_SOCKET=/run/dedalo/dedalo_ts.sock

Failed to determine group credentials: No such process

Cause. The Group= in the systemd unit — or in a drop-in overriding it — names a group that does not exist. The message is misleading: No such process is the errno the name lookup returns for no such group. The engine never starts; systemd fails the unit before executing anything.

This is the normal first failure on a host upgraded from v6, where the service user's primary group is the web server's (gid=33(www-data)) and no group is named after the user.

Diagnose.

id <service user>              # what group does it really have?
getent group <name>            # empty output = that group does not exist
systemctl cat dedalo-ts        # what the unit and its drop-ins actually say

Fix. Three options, best first:

  1. Give the install its own group — the only one that keeps two installs on one host apart:

    sudo groupadd <name> && sudo usermod -g <name> <service user>
    sudo chown -R <service user>:<name> <install home> <media path>
    sudo usermod -aG <name> www-data && sudo systemctl restart apache2
    

    On an upgraded install this is a documented migration step, with the reasoning: Phase E.

  2. Point Group= at the group that exists (systemctl edit dedalo-ts). Unblocks a test box; on a shared host it hands socket access to every member of that group.

  3. Omit Group= and inherit the user's primary group. Only acceptable when that group is install-specific — inheriting users, nogroup or the web server's group is option 2 with the trade-off hidden.

Then, in this order — Restart=always does not clear a start-limit trip:

sudo systemctl daemon-reload
sudo systemctl reset-failed dedalo-ts
sudo systemctl start dedalo-ts

The unit fails before the engine logs anything

journalctl -u dedalo-ts shows a status= code and no output of ours: systemd could not set up the process. The code says which step:

Code Meaning Usual cause
217/USER the user does not resolve User= misspelled, or the account was never created
group credentials the group does not resolve see the entry above
200/CHDIR cannot enter WorkingDirectory wrong path, or the service user cannot traverse into it
203/EXEC cannot execute ExecStart the pinned runtime is not at that path — it is installed per install, so it is easy to point at another install's copy

Reproduce the same conditions by hand before editing the unit again:

sudo -u <service user> <ExecStart bun path> --version
sudo -u <service user> bash -c 'cd <WorkingDirectory> && ls src/server.ts'

The wizard is a 404

Cause. The instance is sealed. Sealing is terminal: the entire install surface answers 404 from then on, across restarts, from any address.

Fix. None, and that is the point. A sealed instance is an installed instance — log in. (If you truly need to re-install: empty database, empty private directory, start again.)

The wizard refuses your address (403)

Cause. Your address is not on the install allowlist. Note that this is the default state, not an unusual one: with DEDALO_INSTALL_ALLOWED_IPS unset the wizard answers the local machine and nobody else, because until it is sealed the installer runs without a password.

Fix. Name the machine you install from — a literal address, a CIDR range, or any (every address; only behind a firewall, and removed once sealed). The engine prints the list in force in its start-up log, next to the INSTALL MODE line, so you can see what it is actually applying:

DEDALO_INSTALL_ALLOWED_IPS=203.0.113.10,10.0.0.0/24

And note that loopback will not match behind a reverse proxy: the address is resolved from the trusted X-Forwarded-For hop, so name the real client address.

The boot fails with core module warm-up: N module(s) failed to evaluate

Cause. A module in the engine's core graph failed to evaluate. The server evaluates the whole graph before it listens, and a failure is fatal by design — a visible crash loop beats a server that serves identical failures for the rest of its life.

Fix. This is a code defect, not a configuration one. The log names the modules. Roll back to the previous ref (upgrading) and report it.

Serving

Every request is a 502

Cause. The proxy cannot connect to the unix socket. Connecting to a unix socket needs write permission on the socket file, and with the default umask the engine creates it srwxr-xr-x — owner only. The proxy runs as www-data or nginx.

Fix.

# dedalo-ts.service
UMask=0007
RuntimeDirectory=dedalo
RuntimeDirectoryMode=0750
usermod -aG dedalo www-data      # nginx: usermod -aG dedalo nginx
systemctl restart dedalo-ts nginx
ls -l /run/dedalo/dedalo_ts.sock # → srwxrwx--- dedalo dedalo

Also confirm the paths agree: SERVER_UNIX_SOCKET in .env, the upstream in the proxy configuration, and the watchdog unit's --unix-socket.

Apache: AH01144: No protocol handler was valid for the URL … (scheme 'http')

Cause. mod_proxy_http is not loaded. mod_proxy alone understands the ProxyPass syntax but has no handler for the http scheme, so it matches the rule and then has nothing to hand the request to.

A host that previously served an older Dédalo shows this reliably: it has proxy_fcgi — the submodule its interpreter pool used — and never needed proxy_http. The engine is reached over HTTP on a unix socket, so the unix: prefix is mod_proxy's part and everything after the | is proxy_http's.

Fix.

apachectl -M | grep proxy            # want proxy_module AND proxy_http_module
sudo a2enmod proxy proxy_http
sudo systemctl restart apache2       # LoadModule needs a restart, not a reload

Apache: AH01630: client denied by server configuration: /etc/apache2/…

Cause. A filesystem path in the vhost is missing its leading slash. Apache resolves a relative path against ServerRoot (/etc/apache2 on Debian/Ubuntu), so Alias /dedalo home/site/dedalo/client/dedalo silently becomes /etc/apache2/home/site/…. No <Directory> block matches that invented path, and the filesystem default outside DocumentRoot is Require all denied — hence a denial rather than a 404. The path in the message is the fabricated one, which is what makes it recognisable.

Fix. Find the offending line and add the slash:

grep -rnE '^\s*(Alias|AliasMatch|DocumentRoot|ScriptAlias|<Directory)\s+"?[^/"]' \
     /etc/apache2/sites-enabled/ /etc/apache2/conf-enabled/
apachectl -S                          # the resolved DocumentRoot per vhost
apachectl configtest && sudo systemctl reload apache2

Two variants produce the same AH01630 with a correct-looking path:

  • the <Directory> block's path does not match the Alias target exactly (a trailing slash, or a symlinked component — Apache matches the resolved path);
  • the web-server user cannot traverse into the tree. A 0700 home denies it before Require all granted is ever read:

    sudo -u www-data ls /home/<site>/<clone>/client/dedalo >/dev/null
    

A client asset 404s at a path that is not in the repo

Symptom. One /dedalo/lib/… or /dedalo/core/… request fails while the page otherwise loads, and the body is the engine's own envelope:

{"ok":false,"request_id":"…","error":{"code":"resource.not_found", }}

Cause. That envelope means the engine answered — the proxy is fine. Third- party libraries are served through an allowlist that maps /dedalo/lib/<id>/<path> to a registered package root, and refuses anything else rather than guessing. So a 404 here says the requested path does not exist in this install's tree.

Almost always the browser is running a cached module from an older client. On an upgrade the hostname does not change, so every returning visitor — and the operator testing the upgrade — carries the previous engine's module graph. Asset paths that moved between versions are then requested at their old location. A plain reload does not help: an ES-module import resolved inside a cached module is not revalidated, which is also why a ?v= on the entry point cannot reach it.

Fix. Confirm it is the cache before touching the server — check whether the requested path exists in the clone:

ls <clone>/node_modules/codex-tooltip/dist/tooltip.js   # the path the CURRENT client asks for
grep -rn "codex-tooltip" <clone>/client/ --include="*.js"

If the deployed client imports a different path than the browser requested, the browser is stale. Empty the cache and hard-reload (Safari: ⌥⌘E then ⌘R; Chrome and Firefox: ⇧-reload with the developer tools open, or "Empty cache and hard reload").

Tell your users once, after an upgrade

This is not only the operator's browser. Anyone who used the old install on the same address needs one hard reload; until then they may see a half-rendered interface with a console full of module errors.

If the path genuinely is missing from the tree, this is not a cache problem — the dependencies were not installed in that clone (see Cannot find package …), or the library is not registered in the allowlist, which is a code change, not a configuration one.

nginx: unknown "dedalo_auth_key" variable

Cause. You included the generated media server rules but not the generated map file. A map cannot live inside server{}, so it ships as a separate file that must be included at http{} scope.

Fix. Include both, or neither:

include /srv/dedalo/media/dedalo_media_protection_map.nginx.conf;   # http{} scope
include /srv/dedalo/media/dedalo_media_protection.nginx.conf;       # server{} scope

nginx: pcre2_compile() failed: missing closing parenthesis

Cause. A known defect in the generated publication-mode rules: the rule-B location regex is emitted unquoted and contains {2,12}, which nginx's configuration lexer reads as a block delimiter.

Fix. Quote that one regex. See reverse proxy → nginx.

nginx: open() … dedalo_media_protection.nginx.conf failed

Cause. The engine has not written the rule files yet. It writes them at boot — but only when a media access mode is configured.

Fix. Set DEDALO_MEDIA_ACCESS_MODE, start the engine once, then reload nginx. Bring the proxy up with the two include lines commented out if you need the site before then.

A large export or a long tool action dies after about a minute

Cause. The proxy's read timeout is lower than the engine's idle timeout, so the proxy hangs up first.

Fix. proxy_read_timeout 300s; (nginx) or ProxyTimeout 300 (Apache) — at least SERVER_IDLE_TIMEOUT_S (255).

The assistant chat or a diffusion progress stream stalls

Cause. The proxy is buffering a streaming response.

Fix. proxy_buffering off; on the API location.

Uploads fail with 413 Request Entity Too Large

Cause. nginx's client_max_body_size defaults to 1 MB, and the client uploads in ~4 MB chunks.

Fix. client_max_body_size 300m; (Apache's default is unlimited — nothing to do there).

A script 404s with MIME type ('application/json') is not executable

Cause. Nothing to do with MIME types. /dedalo/lib/<id>/… is the client-library route, and when it cannot resolve a file it answers with the standard JSON error envelope. The browser refuses to execute a 404's JSON body as a script and reports that, naming neither the missing library nor the reason.

Two reasons it will not resolve, and a container usually has both at once:

  • the library is marked devOnly (the test harness: mocha, chai) and DEDALO_DEV_MODE is not true, so the route refuses it before touching disk;
  • its package is a devDependency, and the image was built with bun install --frozen-lockfile --production, which drops those.

Fix. For the test harness, build the Dockerfile's dev target and set DEDALO_DEV_MODE=true — both are required, and a dev compose overlay does the two together. Rebuilding is not enough on a stack that keeps node_modules in an anonymous volume: Compose reattaches the old volume when it recreates the container, so bring it up with --force-recreate -V.

For any other library this is a real packaging bug, not a configuration one: a library the client loads in production must be a runtime dependency. The client_libs tripwire asserts exactly that, so a red gate is the expected way to find out.

/health answers 503 {"db":"down"}

Cause. PostgreSQL is unreachable or the connection pool is wedged. The health check is deliberately not liveness-only — monitoring must go red when the database is down, not only when the process dies.

Fix. Check PostgreSQL, then the pool: DB_POOL_MAX is per process, and the engine plus every diffusion runner plus the RAG drain all draw against PostgreSQL's max_connections. Watch db_pool_waits on GET /api/v1/counters.

/health answers 503 {"process":"poisoned"}

Cause. A module in the graph failed at request time and its failure is cached for the life of the process. The engine latches this and reports it so that the watchdog recycles the process rather than serving identical failures forever.

Fix. A watchdog recycles the process; how fast depends on the deployment. Under systemd the health timer polls every 30 seconds and restarts the unit. On a docker stack the healthcheck is scripts/ops/container_watchdog.sh, which recycles the container after three consecutive red probes at 30-second intervals — roughly 90 seconds, and only once the container has answered green at least once (so it never recycles a box that is still on the install wizard). Either way it is a code defect — capture the log and report it.

The maintenance widget reports the engine down while the socket probe is green

Symptom. curl --fail --unix-socket /run/dedalo/dedalo_ts.sock http://localhost/health answers 200, but maintenance → system info shows the engine unhealthy, and after a code update the restart poll never confirms the new version.

Cause. The vhost does not route /health. The engine serves it at the origin root, and the browser client probes it there — so the proxy has to carry the route. Without it Apache resolves the path against the DocumentRoot and logs an AH01630 denial for …/health, while nginx hands the request to the vhost's catch-all and returns 404. Nothing is wrong with the engine; only the browser-facing checks can see it.

Fix. Add the rule for your server from Reverse proxy and TLS, reload, and re-run the probe through the domain:

curl --fail https://dedalo.example.org/health

Using it

Nobody can log in, and there is no error message

Cause. SESSION_COOKIE_SECURE defaults to true, so the browser discards the session cookie over plain http://. The login succeeds; the cookie is thrown away; the next request arrives with no session; you land back on the login form.

Fix. Serve over TLS. That is the real fix, and the only fix on a server.

On a local development machine only:

SESSION_COOKIE_SECURE=false

Danger

Never set this to false anywhere a real user can reach. The media-auth cookie inherits the same attribute, so you would also be shipping a working media authorisation value in cleartext.

Users are logged out more often than they expect

Cause. Two clocks, and either one ends a session — see login:

clock key default
Idle timeout SESSION_TTL_SECONDS 3600 (1h without a single request)
Absolute cap SESSION_ABSOLUTE_TTL_SECONDS 43200 (12h since login, unconditional)

Users report the cap as "it logs me out in the middle of my work", because activity cannot postpone it. That is what it is for: the idle window alone never expires a browser that polls in the background.

Fix. Raise the one that is actually biting — do not raise both reflexively. An install on trusted, physically secure workstations can extend the cap; a shared or public workstation wants the idle window short above all.

SESSION_TTL_SECONDS=3600
SESSION_ABSOLUTE_TTL_SECONDS=43200
SESSION_WARNING_SECONDS=300

Users should be warned before this happens: SESSION_WARNING_SECONDS (default 300) puts a notice on screen that many minutes ahead. If nobody is seeing it, check it is not set to 0, and that the browser is not running a stale cached copy of the client (a hard reload settles it).

Running jobs are not interrupted

A logout — voluntary or by timeout — never stops a background import or a publication run. They keep the requesting user on the job record and never re-read the session, and the same user reattaches to a job in progress after logging back in.

Media

A logged-in user gets 404 for every media file

The giveaway is that the application itself works perfectly — records load, searches run — and only images, video, PDFs and 3D files are missing. That is expected: the web server enforces media access, not the engine, so a media failure says nothing about the session.

Cause. Rule A failed: either the browser is not sending the dedalo_media_auth cookie, or the marker file it names does not exist under <media>/.publication/auth/. Rule B cannot cover for it — with no publication markers it never matches, so Rule A is the only door for unpublished media.

Fix. Work outward from the file to the browser:

  1. Confirm the marker store exists and is readable by the web server. It holds one marker per LIVE SESSION, named by that session's cookie value (the day-global <private>/media_auth.json is retired — boot renames it .migrated):

    ls -l /srv/dedalo/media/.publication/auth/
    
  2. Prove the rules are fine by presenting a known-good cookie by hand. If this returns 200, the gate works and the problem is the browser's cookie:

    curl -s -o /dev/null -w '%{http_code}\n' \
      -H "Cookie: dedalo_media_auth=<today's value>" \
      https://example.org/dedalo/media/image/thumb/0/<a real file>.jpg
    
  3. Check the cookie in the browser. It is HttpOnly, so read it in DevTools → ApplicationCookies, never from JavaScript. It is also SameSite=Lax: media embedded cross-site will not carry it, by design.

  4. If it is Secure but you are on plain HTTP, see the login entry above.
  5. Reload the page. The cookie is re-issued on the next authenticated request, so a stale or missing one heals itself without a re-login. If it does not, no store exists yet — log out and back in, which creates one.

Fixed in the current engine

On engines predating the session-bound cookie, this had a much simpler cause: the cookie was minted only at login with a fixed 24-hour Max-Age, while the session renewed on every request. Anyone logged in for longer than a day lost media access with no other symptom. Re-login was the only cure. If you are seeing this on a schedule of roughly once a day, that is what you are looking at.

An anonymous visitor gets 404 for a published record's media

Cause. Rule B did not match. Three candidates, in order of likelihood:

  1. The record is not published. The marker <media>/.publication/pub/{section_tipo}_{section_id} is written by the diffusion engine when you publish. No marker, no access.
  2. The quality folder is not public. Only the configured public qualities are readable anonymously, and master qualities can never be made public — the original and modified folders are filtered out no matter what you configure.
  3. The file name does not parse. Rule B identifies the record from the file name. A file renamed outside Dédalo's naming grammar simply never matches, and stays login-only. That is deliberate; do not loosen it.

Every media file 404s, for everybody, and nginx looks fine

Cause. The root rule. The generated nginx locations carry no root and no alias; they inherit the server's root, which must resolve /dedalo/<media dir>/… onto MEDIA_PATH.

Fix. See reverse proxy → the root rule. With the canonical layout it is root /srv;.

Media is served to everyone (Apache)

Cause. The generated .htaccess is being ignored — silently, and open.

Fix. The media directory needs AllowOverride All (or at least FileInfo Options) and mod_rewrite enabled. This one line is the entire gate on Apache.

My MEDIA_HTACCESS_ADDONS rules are not in the generated .htaccess

Cause. The value is not valid JSON, so it was refused. The boot log says so:

[config] MEDIA_HTACCESS_ADDONS must be a JSON array of strings — ignoring the value.

Almost always this is backslash escaping. The key holds a JSON array, so every backslash in an Apache regex has to be doubled:

# wrong — natural Apache syntax, but invalid JSON
MEDIA_HTACCESS_ADDONS=["RewriteCond %{REMOTE_ADDR} ^10\.0\.","RewriteRule ^ - [L]"]

# right — backslashes doubled for JSON
MEDIA_HTACCESS_ADDONS=["RewriteCond %{REMOTE_ADDR} ^10\\.0\\.","RewriteRule ^ - [L]"]

Fix. Correct the escaping and restart. Only your addon lines were dropped — the access gate itself is unaffected and stayed closed, which is the intended failure direction: a malformed addon must never become half a directive inside a live .htaccess (that would make Apache reject the whole media directory).

An uploaded image produces no derivative and no thumbnail

Cause. ImageMagick is not installed, or its binaries cannot be resolved.

Fix.

command -v magick convert identify

Either is fine — the engine probes for magick first and falls back to convert/identify, which is what Ubuntu 24.04 ships. If they live somewhere unusual, set DEDALO_BINARY_BASE, or the individual keys (DEDALO_MAGICK_PATH, DEDALO_IDENTIFY_PATH).

Video uploads work, but playback does not start until the file has downloaded

Cause. qt-faststart is missing, so the MP4 index is still at the end of the file.

Fix. Install it (it ships with ffmpeg on most distributions) or point at it: DEDALO_AV_FASTSTART_PATH=/usr/local/bin/qt-faststart.

Unpublishing a record does not take effect

Cause. The web server is caching the stat() of the publication marker.

Fix. open_file_cache off; on the media locations (or open_file_cache_valid ≤ 2 s). Behind a CDN, purge the record's media paths on unpublish — the origin denies immediately, downstream caches do not.