Deployed live at 91e9410 (gluetun+marreta+caddy), authentik provider pk 13,
guard chain verified end-to-end. Documented that Laravel APP_KEY must carry the
base64: prefix — a bare base64 key 500s every app route with 'Unsupported
cipher or incorrect key length' while /up still passes.
10 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| reference | Marreta on Dankclaw | The content-cleaning proxy (manualdousuario/marreta, Laravel/serversideup fpm-nginx) deployed as a rootless Quadlet pair that shares SearXNG's Gluetun netns for egress and sits behind the Anubis + Authentik guard chain at marreta.franksdank.cloud. |
|
2026-09-12 |
Marreta
Marreta is a self-hosted content-cleaning proxy: you hand it a URL and it
returns a stripped, readable version of the page — no paywall chrome, no
tracking, no cookie walls. It is a Laravel 13 app on the
serversideup/php:8.4-fpm-nginx image, with an optional headless-browser
companion for JavaScript-heavy pages.
Host: Dankclaw — vhost: marreta.franksdank.cloud (Caddy, guard-chained)
— Upstream: host-published port 8082 (inside the Gluetun netns)
— Source: https://github.com/manualdousuario/marreta
What it does (from the upstream README)
- Fetches a target URL with a cascade of strategies: plain HTTP (curl), a headless browser (Lightpanda) for JS pages, and the Internet Archive (Wayback) as a last resort.
- Strips paywall/consent/tracking markup and re-renders clean HTML.
- Ships a Filament admin panel at
/admin(login =ADMIN_EMAIL/ADMIN_PASSWORD) for settings and usage. - Health endpoint at
/up(Laravel's built-in health route).
Topology
Marreta runs as two rootless Quadlet containers that both join Gluetun's network namespace — the same tunnel SearXNG uses — so every outbound fetch exits over WireGuard, never the home IP.
graph LR
C[Client] -->|HTTPS| TLS[Caddy TLS edge]
TLS -->|challenge| AN[Anubis :8923]
AN -->|auth decision| A16[Caddy :8016]
A16 -->|Authentik| AUTH[authentik-server]
AUTH -->|authenticated| A17[Caddy :8017]
A17 -->|host marreta| PUB[host :8082]
subgraph G[gluetun netns - WireGuard egress]
PUB -->|8085| M[marreta :8085]
M -->|ws loopback| B[marreta_browser :9222]
end
M -->|fetch| WG[WireGuard tunnel]
B -->|fetch| WG
| Unit | File | Image | Netns | Listens |
|---|---|---|---|---|
marreta.service |
dankclaw/marreta/quadlet/marreta.container |
ghcr.io/manualdousuario/marreta:latest |
gluetun.container |
8085 (nginx) |
marreta-browser.service |
dankclaw/marreta/quadlet/marreta-browser.container |
docker.io/lightpanda/browser:nightly |
gluetun.container |
9222 (CDP) |
Both units are boot-persistent (WantedBy=default.target) and self-healing
(Restart=always), per the repo's Boot Persistence rule.
The port-collision resolution (why 8085)
The shared netns already has SearXNG on 8080. Marreta's image
(serversideup/php:8.4-fpm-nginx) defaults to NGINX_HTTP_PORT=8080, so a
naive deploy would fail the moment nginx tried to bind a port SearXNG holds.
The fix is a single env var, verified against the serversideup source:
- The image sets
ENV NGINX_HTTP_PORT="8080"and the site template islisten ${NGINX_HTTP_PORT} default_server;. - The image does not bake
conf.d/*.conf(itrm -rfs them at build); the entrypoint10-init-webserver-config.shrunsenvsubstover everyNGINX_*variable at container start. - So
Environment=NGINX_HTTP_PORT=8085in the quadlet relocates the listen port off the collision with no custom build.
Marreta then publishes nothing of its own — the netns owner (Gluetun)
publishes 8082:8085 on its behalf, exactly as it publishes 8081:8080
for SearXNG. Caddy dials 192.168.0.143:8082.
Why netns sharing, not PROXY_URL
Marreta supports a PROXY_URL env var, but it only covers the curl
fetch strategy. The BrowserFetchStrategy connects to Lightpanda over CDP
and passes no proxy — the browser makes its own connections. If the
browser ran outside the tunnel it would leak the host's real egress IP on
every JS-rendered page. Netns sharing is the only way to put both the
app and the browser on the same exit, which is what "share the same Gluetun
egress as SearXNG" requires. The browser is therefore reached on loopback
(ws://127.0.0.1:9222) inside the shared netns.
Guard chain (behind Authentik)
marreta.franksdank.cloud follows the standard chain documented in
Anubis and Authentik:
- Public edge — the vhost proxies everything to
anubis:8923(bot challenge), withheader_up X-Real-Ip {remote_host}. - Auth decision — Anubis dials Caddy's
http://:8016, which proxies whole-request toauthentik-server:9000. - Post-auth routing — authenticated traffic returns to
http://:8017, where@marreta host marreta.franksdank.cloudroutes toreverse_proxy 192.168.0.143:8082.
Access model: any authenticated user. The post-auth handler carries no
group gate (unlike the homepage /admin, which requires the
homepage-admins group). Anyone who can log in via Authentik can use
Marreta.
Required provider (manual, one-time). Before the vhost goes live, a proxy provider must exist on the embedded outpost or authenticated traffic hits the outpost error page instead of the app:
# podman exec -i authentik-server ak shell < provider.py
from authentik.core.models import Group
from authentik.providers.proxy.models import ProxyProvider
p = ProxyProvider.objects.filter(name="marreta").first() or ProxyProvider()
p.name = "marreta"
p.external_host = "https://marreta.franksdank.cloud/" # exact, trailing slash
p.internal_host = "http://caddy:8017"
p.save()
This is the "provider first, then route" ordering from the guard-chain runbook — the Anubis guard set, the Caddy TLS edge, and the provider list must change together.
Scheme / proxy trust
Marreta ships trustProxies(at: '172.16.0.0/12,10.0.0.0/8,192.168.0.0/16,127.0.0.1')
and forces https when APP_URL starts with https://. With
APP_URL=https://marreta.franksdank.cloud the app honors the forwarded
scheme and client IP through the whole chain, so there is no redirect loop
or mixed-content breakage.
Secrets
Three values, seeded into /home/dank/Containers/marreta/env (mode 0600,
dank-owned so the rootless unit can read it — the grafana convention, not
/opt):
Repo .env key |
Mapped to (app) | Notes |
|---|---|---|
MARRETA_APP_KEY |
APP_KEY |
Laravel encryption key. Must be the base64:-prefixed form: base64:$(openssl rand -base64 32). A bare base64 string (no prefix) is read as a raw key of the wrong length and every app route 500s with Unsupported cipher or incorrect key length at Encrypter.php:61 (only /up, which skips the encrypter, still passes). Changing it after first boot invalidates encrypted session/cookie data. |
MARRETA_ADMIN_EMAIL |
ADMIN_EMAIL |
Admin panel login (created on first boot). |
MARRETA_ADMIN_PASSWORD |
ADMIN_PASSWORD |
Admin panel password. |
The quadlet maps them with Environment=APP_KEY=${MARRETA_APP_KEY} etc.
The .container files hold no secrets — only ${VAR} placeholders.
The SQLite DB + cache live in the marreta_storage named volume at
/var/www/html/storage/app (podman auto-creates it and copyups the image's
www-data ownership so the app can write).
Deploy
./scripts/deploy.sh dankclaw --service marreta
deploy_marreta seeds the env file (only if absent), installs both
.container units, ensures Gluetun is up, then restarts the browser before
the app. Because Marreta shares Gluetun's netns, the planner treats it as
part of the Gluetun netns family: a change to gluetun.container or
searxng.network also selects marreta (a Gluetun recreation tears the
netns down and would otherwise orphan it).
First deploy note: adding
PublishPort=8082:8085togluetun.containermeans the first deploy recreates Gluetun, which briefly cycles SearXNG and Marreta together. Plan for the short tunnel restart.
Verification
- Unauthenticated:
curl -s https://marreta.franksdank.cloud/→ the Anubis challenge page (grep body forchallenge/not a bot). A 502 here means the chain is mis-wired. - Post-auth routing:
podman exec caddy sh -c "grep -n -A3 '@marreta host' /etc/caddy/Caddyfile"after the deploy's reload line. - Egress: from inside the netns,
podman exec marreta curl -s https://ifconfig.comust return the VPN exit IP, not the home IP. - Port:
podman exec marreta sh -c 'curl -fsS localhost:8085/up'→ 200;curl http://192.168.0.143:8082/up→ 200. - Test seam:
bats tests/marreta_quadlet.bats(17 contracts).
Live verification (deployed 2026-09-12, rev 91e9410)
Deployed via deploy.sh dankclaw --service gluetun --service marreta --service caddy (gluetun first to recreate the tunnel with the new 8082:8085 publish,
which also cycles SearXNG's netns ref). Authentik ProxyProvider marreta
registered (pk 13, external_host=https://marreta.franksdank.cloud/,
internal_host=http://caddy:8017). Results:
- Unauth edge → Anubis challenge page (not 502). TLS cert issued via DNS-01.
- Post-auth sim (
caddy :8017withHost: marreta.franksdank.cloud) → 200. marretacontainer health: healthy;/and/admin/login→ 200.- Netns sharing proven:
gluetun,marreta,marreta_browserall on the samenet:[…]inode; marreta reads the CDP endpoint (GET 127.0.0.1:9222/json/version→Lightpanda/1.0). - SearXNG unaffected:
:8081→ 200 after the tunnel recreate.
Gotcha hit during deploy: the first seed used a bare-base64
APP_KEY, which 500s every app route (see Secrets above). Fixed by re-keying with thebase64:prefix before any encrypted data was persisted.
Citations
- Marreta upstream: https://github.com/manualdousuario/marreta (README,
compose.yml,bootstrap/app.php,app/Services/Fetch/BrowserFetchStrategy.php,lang/directory listing). - serversideup/php fpm-nginx port mechanism:
serversideup/docker-phpsrc/variations/fpm-nginx/Dockerfile(NGINX_HTTP_PORT="8080") andsrc/s6/etc/entrypoint.d/10-init-webserver-config.sh(envsubstoverNGINX_*at start;conf.d/*.confremoved at build). - Lightpanda browser image: https://hub.docker.com/r/lightpanda/browser
(
nightlytag). - Guard-chain ordering and provider-first rule: Anubis, Authentik,
and the
anubis-guard-chain-fliprunbook. - Netns egress pattern: SearXNG, Gluetun, Container Network Namespaces.