Skip to content

Reverse proxy and TLS

See also: Production install · Media protection · Troubleshooting · H.264 streaming module

Overview

Dédalo's engine listens on a unix socket and nothing else. The reverse proxy is not an optional performance layer: it owns TCP and TLS, it serves the client static files, and — most importantly — it is what enforces media access control. This page wires it, for nginx and for Apache.

What the proxy is responsible for

flowchart LR
    B[Browser] -->|443 TLS| P[Reverse proxy]
    P -->|unix socket| D[Dedalo engine · Bun]
    P -->|files| C[client/dedalo/ · static]
    P -->|files + stat gate| M[MEDIA_PATH]
    D -->|generates the rule files| M
    D --> PG[(PostgreSQL)]
URL space Served by Notes
/api/v1/…, /dedalo/core/api/… proxy → socket the JSON API, uploads, the raw/environment/counters views
/dedalo/lib/… proxy → socket third-party browser libraries; there is no client/dedalo/lib/ directory
/dedalo/tools/…, /dedalo/core/tools_common/… proxy → socket tool assets live in the repo's tool trees, outside client/
/dedalo/core/component_text_area/tag/ proxy → socket the inline-tag image factory
/dedalo/install/import/ontology/… proxy → socket only when this instance is an ontology master
/dedalo/install/code/… proxy → socket the release archives a code master serves; this is the URL the update manifest advertises
/dedalo/install/import/hierarchy/… proxy → socket hierarchy export downloads (admin-session-gated)
/dedalo/ai_models/… proxy → socket the local AI model store, fetched by the browser from the page origin; session-gated — an anonymous request gets a 404
/dedalo/upload_tmp/… proxy → socket staged-upload previews, before the record is saved
/dedalo/media/… proxy, from MEDIA_PATH gated by the generated rules — see below
everything else under /dedalo/… proxy, from client/dedalo/ static files

Never proxy media through the engine

Media files reach tens of gigabytes. Authorisation is one stat() performed by the web server itself, which is why sendfile, HTTP Range and the H.264 ?start= clipping keep working. Put the engine in the byte path and you break streaming, seeking and memory headroom in one move. (There is a media route inside the engine, for developers with no web server in front. It applies no per-record access control, and it answers only on the TCP dev listener while protection is unconfigured — so behind this proxy, on the unix socket, it never runs. Leave MEDIA_DEV_ROUTE_ENABLED unset; setting it to true would force the engine into the byte path on every listener.)

The three generated rule files

The engine generates the web-server rules; the proxy enforces them. The files are written into MEDIA_PATH at boot, at every login, and by the media_control maintenance widget — idempotently, guarded by a config hash embedded in each file.

Generated file (in MEDIA_PATH) Web server What you must do
.htaccess Apache nothing — honoured automatically, provided the directory has AllowOverride
dedalo_media_protection.nginx.conf nginx include it in the media server{}
dedalo_media_protection_map.nginx.conf nginx include it at http{} scope

Both nginx includes, or none

The map file defines $dedalo_auth_key, and a map cannot live inside server{}. Include the server file without the map and nginx refuses to start (unknown "dedalo_auth_key" variable). That is deliberate: a half-wired gate must never boot half-open.

Reloads: what needs one and what does not

  • A mode change needs an nginx reload (nginx -t && nginx -s reload). nginx reads its configuration at reload; Apache re-reads .htaccess on every request, so Apache needs nothing.
  • The daily cookie rotation needs no reload, ever. That is precisely why the cookie name (dedalo_media_auth) is fixed and only its value rotates: the rules never name a value, they only test whether the file named by the cookie exists.

If you do not need media access control

Not every collection needs it. An internal instance, or one whose media is public by policy, can serve the media tree openly — and then the whole gate, its two includes and its reload discipline disappear from your install.

Leave DEDALO_MEDIA_ACCESS_MODE unset. The engine writes no rule files, and what you do next differs by web server:

What to do Why
nginx uncomment the open location /dedalo/media/ block in deploy/nginx.conf the generated include is the only media location — without either, every media URL falls through to the client alias and 404s
Apache nothing the vhost's Alias /dedalo/media already serves the tree; the generated .htaccess only ever restricts it, and there is now no .htaccess to generate

Open means open

No login is required and no per-record or per-project rule applies: anyone who can reach the server reads every image, document and recording. Decide this deliberately for the collection you actually hold — a restricted fonds, an embargoed deposit or personal data makes it the wrong choice, whatever the rest of the deployment looks like.

Exactly one of the two must be active. Neither, and media 404s; both, and you have wired an open location in front of a gate you believed was protecting you.

The root rule

The generated nginx locations carry no root and no alias. They inherit the server's root. So the server root must satisfy:

<root> + /dedalo/<DEDALO_MEDIA_DIR>/…   ==   MEDIA_PATH/…

With this manual's canonical layout (MEDIA_PATH=/srv/dedalo/media, media directory media), that is exactly root /srv;.

Get this wrong and the symptom lies to you

A mismatched root produces a 404 on every media file while the access gate itself is working perfectly. It looks like a permissions problem and it is not. Test it against a file you know exists.

Apache has no such subtlety: an Alias maps the URL onto MEDIA_PATH directly.

nginx

  1. Install nginx first:
apt install -y nginx      # RHEL family: dnf install -y nginx
  1. Copy the reference configuration:
cp /opt/dedalo/master_dedalo/deploy/nginx.conf /etc/nginx/conf.d/dedalo.conf
  1. Start the server and change the configuration to include the media protection files.

Bring it up in THIS order — the two media include lines are commented on purpose

The include lines below point at files the engine only writes on its first boot. Install with them commented out or nginx refuses to start against missing files. In order:

  1. Install this config as shown (both include lines commented). nginx starts; media is simply not served yet — the safe failure.
  2. Confirm the engine has run once (step 10) — it writes the rule files into MEDIA_PATH at boot.
  3. Uncomment both include lines, then nginx -t && systemctl reload nginx.

3.1 Start the nginx service:

systemctl enable nginx
systemctl start nginx

3.2 Test it in browser:

3.3 Check if the media files was created correctly.

ls -la /srv/dedalo/media/dedalo_media_protection_map.nginx.conf
ls -la /srv/dedalo/media/dedalo_media_protection.nginx.conf

3.4 If the files are created correctly, uncomment the include lines in the nginx configuration and reload the service.

nano /etc/nginx/conf.d/dedalo.conf
nginx -t && systemctl reload nginx

3.5 Test it in browser again.

Certificate

If you see a certificate error, it means that the certificate is not installed correctly. You can install it manually or use the certbot instuctions to install it automatically.

The config is reproduced here with the load-bearing lines called out.

# --- http{} scope (a conf.d file is already inside http{}) -------------------
# include /srv/dedalo/media/dedalo_media_protection_map.nginx.conf;   # ← uncomment after step 10

upstream dedalo_ts {
    server unix:/run/dedalo/dedalo_ts.sock;
}

# Request-rate ceiling for the API door, per source address. The engine bounds
# what one request may carry; this bounds how many arrive, and it costs the
# engine nothing because nginx refuses before the proxy hop. `nodelay` releases
# the burst immediately — a page load fans out into a dozen parallel component
# reads, so a burst is normal and only a flood is refused.
limit_req_zone $binary_remote_addr zone=dedalo_api:10m rate=20r/s;

server {
    listen 80;
    server_name dedalo.example.org;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;                       # multiplexes the 36-module client boot graph
    server_name dedalo.example.org;

    ssl_certificate     /etc/letsencrypt/live/dedalo.example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/dedalo.example.org/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    root /srv;                      # THE ROOT RULE — see above
    client_max_body_size 300m;      # nginx defaults to 1m; every upload would 413

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options    "nosniff"    always;
    add_header X-Frame-Options           "SAMEORIGIN" always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;

    # Media — the GENERATED gate (written on first boot). Uncomment after step 10.
    # include /srv/dedalo/media/dedalo_media_protection.nginx.conf;
    open_file_cache off;            # a stat() cache delays an unpublish

    # Liveness probe. The engine serves its health check at the ORIGIN ROOT
    # `/health`. The watchdog hits it over the unix socket, but the browser-facing
    # system_info maintenance widget probes it over HTTP too, so it must be
    # reachable here — otherwise the catch-all `location / { 404 }` below swallows
    # it and the widget reports a healthy engine as down.
    location = /health {
        proxy_pass http://dedalo_ts;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
    }

    # API + dynamic routes. A regex location outranks every prefix location, so
    # this keeps precedence over the /dedalo/ static alias below.
    location ~ ^/(api/v1/|dedalo/core/api/) {
        limit_req zone=dedalo_api burst=40 nodelay;
        proxy_pass http://dedalo_ts;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300s;    # >= SERVER_IDLE_TIMEOUT_S (255)
        proxy_send_timeout 300s;
        proxy_buffering off;        # SSE + NDJSON streaming
    }

    # Dynamic routes that live under /dedalo/ but are NOT static files. No client
    # subtree answers any of them, so an omitted line does not fall back to the
    # engine — the alias below serves a 404 and the request never reaches the
    # socket. The last three are conditional on what this install does (code
    # master, hierarchy export, in-browser AI) and 404 harmlessly otherwise.
    location /dedalo/lib/                            { proxy_pass http://dedalo_ts; }
    location /dedalo/tools/                          { proxy_pass http://dedalo_ts; }
    location /dedalo/core/tools_common/              { proxy_pass http://dedalo_ts; }
    location = /dedalo/core/component_text_area/tag/ { proxy_pass http://dedalo_ts; }
    location /dedalo/install/import/ontology/        { proxy_pass http://dedalo_ts; }
    location /dedalo/install/code/                   { proxy_pass http://dedalo_ts; }
    location /dedalo/install/import/hierarchy/       { proxy_pass http://dedalo_ts; }
    location /dedalo/ai_models/                      { proxy_pass http://dedalo_ts; }
    location /dedalo/upload_tmp/                     { proxy_pass http://dedalo_ts; }

    # Client static files. Served IN PLACE (not content-hashed) — they must
    # revalidate, so they are NEVER immutable.
    location /dedalo/ {
        alias /opt/dedalo/master_dedalo/client/dedalo/;
        etag on;
        add_header Cache-Control "no-cache";
        gzip on;
        gzip_types text/css application/javascript application/json image/svg+xml;
        gzip_min_length 1024;
        location ~* \.(png|jpe?g|gif|webp|ico|woff2?|ttf|otf)$ {
            add_header Cache-Control "public, max-age=3600";
        }
    }

    location = / { return 302 /dedalo/core/page/; }
    location   / { return 404; }
}

Several domains on one box

This is a single-domain vhost. To serve more domains, add one upstream and one server{} per domain, each pointing at that instance's socket and MEDIA_PATH — see Multiple instances on one server.

Known defect: quote the rule-B location regex

In publication mode the generated dedalo_media_protection.nginx.conf emits its rule-B location as an unquoted regex, and that regex contains {2,12}. nginx's configuration lexer treats { and } as block delimiters, so it truncates the token and refuses to start:

nginx: [emerg] pcre2_compile() failed: missing closing parenthesis in "^/dedalo/media/(?:…"

Until the generator is fixed, wrap that one regex in double quotes:

fix
location ~ "^/dedalo/media/(?:av/404|…)…$" {

The edit survives: the file is only rewritten when the embedded # config-hash: line stops matching the current configuration, and quoting does not change the hash. Re-apply it after any change to the media mode or the public quality list. private mode is unaffected — it generates no regex location.

Apache

The full reference vhost is shipped as deploy/apache.conf (the twin of deploy/nginx.conf). Install Apache, enable the modules, then copy it:

apt install -y apache2      # RHEL family: dnf install -y httpd

cp /opt/dedalo/master_dedalo/deploy/apache.conf \
   /etc/apache2/sites-available/dedalo.conf     # RHEL: /etc/httpd/conf.d/dedalo.conf
a2ensite dedalo && apachectl configtest && systemctl reload apache2

The ProxyPass rules must come before the aliases, and Alias /dedalo/media must come before Alias /dedalo: the first match wins. The abridged shape (see the file for the full comments):

a2enmod ssl headers http2 rewrite proxy proxy_http
<VirtualHost *:80>
    ServerName dedalo.example.org
    Redirect permanent / https://dedalo.example.org/
</VirtualHost>

<VirtualHost *:443>
    ServerName dedalo.example.org
    Protocols h2 http/1.1

    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/dedalo.example.org/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/dedalo.example.org/privkey.pem
    Include /etc/letsencrypt/options-ssl-apache.conf

    ProxyPreserveHost On
    # >= SERVER_IDLE_TIMEOUT_S (255). Apache honours `#` only at the start of a
    # line — a comment appended to a directive is parsed as an argument.
    ProxyTimeout 300

    # --- Liveness probe → the unix socket ---------------------------------
    # The engine serves /health at the ORIGIN ROOT and the browser client
    # probes it there. Omit this and Apache resolves /health against the
    # DocumentRoot and denies it (AH01630), so the maintenance widget reports
    # a healthy engine as down and the post-update restart poll never confirms.
    ProxyPass        /health unix:/run/dedalo/dedalo_ts.sock|http://localhost/health
    ProxyPassReverse /health unix:/run/dedalo/dedalo_ts.sock|http://localhost/health

    # --- API + dynamic routes → the unix socket ---------------------------
    ProxyPass /api/v1/                              unix:/run/dedalo/dedalo_ts.sock|http://localhost/api/v1/
    ProxyPass /dedalo/core/api/                     unix:/run/dedalo/dedalo_ts.sock|http://localhost/dedalo/core/api/
    ProxyPass /dedalo/lib/                          unix:/run/dedalo/dedalo_ts.sock|http://localhost/dedalo/lib/
    ProxyPass /dedalo/tools/                        unix:/run/dedalo/dedalo_ts.sock|http://localhost/dedalo/tools/
    ProxyPass /dedalo/core/tools_common/            unix:/run/dedalo/dedalo_ts.sock|http://localhost/dedalo/core/tools_common/
    ProxyPass /dedalo/core/component_text_area/tag/ unix:/run/dedalo/dedalo_ts.sock|http://localhost/dedalo/core/component_text_area/tag/
    ProxyPass /dedalo/install/import/ontology/      unix:/run/dedalo/dedalo_ts.sock|http://localhost/dedalo/install/import/ontology/
    ProxyPass /dedalo/install/code/                 unix:/run/dedalo/dedalo_ts.sock|http://localhost/dedalo/install/code/
    ProxyPass /dedalo/install/import/hierarchy/     unix:/run/dedalo/dedalo_ts.sock|http://localhost/dedalo/install/import/hierarchy/
    ProxyPass /dedalo/ai_models/                    unix:/run/dedalo/dedalo_ts.sock|http://localhost/dedalo/ai_models/
    ProxyPass /dedalo/upload_tmp/                   unix:/run/dedalo/dedalo_ts.sock|http://localhost/dedalo/upload_tmp/

    # --- Entry points: the client tree has no index.html above core/page/ --
    RedirectMatch 302 "^/dedalo/?$"      /dedalo/core/page/
    RedirectMatch 302 "^/dedalo/core/?$" /dedalo/core/page/
    RedirectMatch 302 "^/$"              /dedalo/core/page/

    # --- Media: the generated .htaccess lives inside MEDIA_PATH -----------
    Alias /dedalo/media /srv/dedalo/media
    <Directory /srv/dedalo/media>
        # WITHOUT AllowOverride the generated .htaccess is ignored — silently,
        # and OPEN. This single line is the whole media gate on Apache.
        AllowOverride All
        Options -Indexes -ExecCGI
        Require all granted
    </Directory>

    # --- Client static files ----------------------------------------------
    Alias /dedalo /opt/dedalo/master_dedalo/client/dedalo
    <Directory /opt/dedalo/master_dedalo/client/dedalo>
        Options -Indexes
        AllowOverride None
        Require all granted
    </Directory>

    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
</VirtualHost>

AllowOverride All is not a style choice

Apache reads the generated .htaccess only if the directory allows overrides. Without it the file is ignored silently, and the whole media tree — originals included — is world-readable. mod_rewrite must be enabled for the same reason.

Serving audiovisual fragments by time range needs an extra Apache module; nginx has the equivalent built in. See H.264 streaming module.

TLS with Let's Encrypt

snap install --classic certbot
ln -sf /snap/bin/certbot /usr/bin/certbot

certbot --nginx -d dedalo.example.org      # or: certbot --apache -d …
certbot renew --dry-run                    # the snap installs the renewal timer

TLS is a hard requirement, not a recommendation

SESSION_COOKIE_SECURE defaults to true, so a browser will not store the session cookie over plain HTTP and nobody can log in. The media-auth cookie carries the same attributes by construction — a Secure session cookie next to a cleartext media cookie would leak an authorisation value on a single plaintext hop.

The settings that bite

Setting Value What breaks otherwise
proxy read timeout SERVER_IDLE_TIMEOUT_S (255 s; use 300 s) a large export or a long tool action is killed by the proxy one hop before the engine would have finished it
response buffering off on the API location the assistant chat (SSE), diffusion progress and NDJSON exports stall or die; the engine sends X-Accel-Buffering: no and 15-second heartbeats, but a buffering proxy defeats them
request rate limit_req on the API location (nginx limit_req_zone + limit_req; Apache: mod_ratelimit/mod_evasive) nothing bounds how fast one source may call the API; the engine's login throttle is per-account, so a caller rotating usernames is unlimited (audit SEC-21)
max request body ≥ 256 MiB (nginx client_max_body_size; Apache's default is unlimited) every upload fails with 413 — nginx's default is 1 MB, and the client uploads in ~4 MB chunks
TRUSTED_PROXY_HOPS the number of proxies that append X-Forwarded-For (default 1) the login throttle keys on the wrong address: too low and an attacker forges a fresh throttle bucket per request; too high and every user shares one bucket
open_file_cache off (or _valid ≤ 2 s) on the media locations unpublishing a record does not take effect until the cache expires
socket permissions see production 502 on every request — connecting to a unix socket needs write permission on it

Verify

# The engine answers over the socket, and Postgres is reachable.
curl --fail --unix-socket /run/dedalo/dedalo_ts.sock http://localhost/health

# The same probe THROUGH the proxy — the surface the browser client uses.
curl --fail https://dedalo.example.org/health

# The proxy serves the client over TLS.
curl -I https://dedalo.example.org/dedalo/core/page/

# A media file honours Range (206 — proof that nothing is in the byte path).
curl -I -H 'Range: bytes=0-99' https://dedalo.example.org/dedalo/media/image/thumb/<a-real-file>.jpg

# The marker store is never served (404).
curl -I https://dedalo.example.org/dedalo/media/.publication/auth/

A /health that is green over the socket but 404s or 403s over the domain means the /health rule above is missing: the engine is fine, and only the browser-facing checks — the maintenance widget and the post-update restart poll — can see the difference.

A Range request that answers 200 with a full body — instead of 206 — means something has been put in the media byte path. Find it and take it out.