The CPU image is built, so what is true now moves from the proposal into
docs/architecture/docker.md, written from the code: the container, the
entries, Caddy and HTTPS, the frontend without Electron, sign-ins, the
image, Compose, the dev stack, CI and releases, with what is not built as
Known gaps. The GPU images, not built, stay a proposal, as
proposals/docker-gpu.md. Every link and code comment that named the
proposal points at the new doc.
It published on 127.0.0.1 only, so testing from a phone needed an override
to remember, and dev behaved unlike a real install. Both ports default to
0.0.0.0 now; SURFSENSE_BIND=127.0.0.1 keeps it to the machine. The commands
list says which port each answers on.
Sign-in is always on. Whoever opens a new instance first creates its
password, from any browser, with one field and an eye, as Immich and Open
WebUI have their first account. SURFSENSE_PASSWORD presets it, and the app
shows a password set there locked; without the variable it becomes the
app's to change. SURFSENSE_AUTH=none turns sign-in off for a server whose
own proxy or VPN decides who reaches it.
Compose now publishes on every network the server is on, so phones and
laptops reach a fresh install at once. docs/architecture/docker-access.md
holds every case, from a home server to a public domain.
docker/surfsense/dev/compose.yml runs the image's new `app` stage, everything
but the SPA, with the source mounted over it, on 127.0.0.1:4930 beside a real
install on 4929. Vite runs as its own service and Caddy proxies the SPA's route
to it, so the frontend hot-reloads; under --watch a backend, sidecar, entry or
Caddy change restarts the app container in a few seconds instead of a rebuild.
The SPA's route moves into caddy/spa.caddy, the one file the dev stack replaces;
the shipped image is unchanged and passes image.test.ts.
TDF's Linux build, unpacked into /data, could not start: a minimal Ubuntu
lacks NSS, D-Bus, cairo, GLib, CUPS, a few X11 libraries and any font.
The image installs them, about 21 MB, found by converting in throwaway
containers until it worked, and an image test checks they are present.
SURFSENSE_TLS=internal serves each allowed host under Caddy's own
certificate authority; SURFSENSE_DOMAIN gets a Let's Encrypt certificate.
The headless entry picks the Caddyfile and refuses, at start, a setting
it cannot serve. Both share one set of routes, and Compose publishes 443
on SURFSENSE_HTTPS_PORT. The API learns the public domain, for plugin
sign-ins there.
The desktop's staging scripts take --out, and llama.cpp's and sd.cpp's
--variant cpu, so the image stages each runtime from the same source:
llama.cpp from upstream's CPU build, sd.cpp compiled without Vulkan,
audio.cpp as the desktop's Linux build. The bundled Kokoro voice ships
too, so podcasts work from the first start.
headless/access/ answers Caddy's forward_auth for every request, and the
app's /access/* routes. One password per instance, scrypt-hashed in
/data/access.json; sessions are HMAC-signed HttpOnly, SameSite=Strict
cookies, Secure over HTTPS, that a password change ends; five wrong tries
a minute per client answer 429; sign-in and sign-out refuse another site's
Origin. Without a password only the host is answered, by its Host and by
the client address Caddy sees. SURFSENSE_PASSWORD sets the password at
start and reaches no sidecar; SURFSENSE_ALLOWED_HOSTS limits the hosts;
SURFSENSE_BIND returns to compose. access.caddy replaces access-local.caddy.
The image tests start a second container with a password and check the
sign-in through Caddy, a forged X-Forwarded-Host included.
A Host header is the sender's to choose, so Caddy's local-only check stops
a browser on the LAN and DNS rebinding, not a client that sends
Host: localhost. With SURFSENSE_BIND widening the port, an instance with no
password was reachable that way. Compose now publishes on 127.0.0.1 only;
SURFSENSE_BIND returns with the password in step 10.
docker compose exec ran with HOME=/home/surfsense, so the dialog's install
command put Claude Code outside the volume, where the backend, on
HOME=/data/home, never found it and an image update lost it; and the image
had no curl, while the pipe still exited 0. The image now sets HOME on
/data for every process with its .local/bin on PATH, ships curl, and the
command runs under bash with pipefail.
uv and pnpm caches are restored everywhere but saved only by dev pushes, so
pull request and tag caches, never reused, stop pushing dev's out of the
repository's 10 GB. pnpm comes from pnpm/setup, which can restore without
saving, as setup-node cannot. The image's build cache moves to GHCR, which
every branch, pull request and tag can read: dev pushes and release tags
write it, pull requests never do. Backend unit tests run one worker per
core with pytest-xdist and list the slowest; integration tests stay serial
until the ones that clash in parallel share an xdist_group. One test named
its parameters by a PDF's bytes, whose ID differs per worker; it names them
by file now.
headless/ becomes its own package, typechecked and tested in Docker Tests
rather than through Electron's run. image.test.ts replaces smoke.sh: off
unless SURFSENSE_TEST_IMAGE names an image, it checks health through
Caddy, the app and its script, the 403s, a clean stop, and that a new
container on the same /data keeps its secret. Docker Tests adds jobs for
headless, the frontend's docker-tagged tests and the backend's image tests
in the test stage, and checks the compose file; Release Docker runs the
same image tests before it pushes.
Tests whose outcome depends on what the image provides, its user, process
reaping, bundled models and system libraries, carry pytest.mark.image and
still run with the rest. The Dockerfile's backend-tests stage, never
shipped, runs them as uid 1000 under tini on the image's Ubuntu, Python and
models. Four figure-caption tests fail there and on a host given the same
Docling pack; no CI fetched the pack before, so they are strict xfails.
As the desktop has desktop-tests.yml and release-local.yml, the image now
has docker-tests.yml, which builds and smokes it on pull requests and dev
pushes and never pushes, and release-docker.yml, which pushes X.Y.Z on a
tag and moves latest and X.Y when the release is published. Both run
docker/surfsense/smoke.sh, which also runs against a local image. dev
pushes no longer publish dev and git-<sha> images.
The image has no Electron bridge, so About showed neither the version nor
the release notes link. Its frontend is now built with
VITE_SURFSENSE_VERSION from surfsense_local/VERSION, the one version the
image shares with the desktop app, and a VERSION bump rebuilds the image.
The bug and feature forms list Docker beside the desktop app, and a report
started from the image, built with VITE_SURFSENSE_RUNTIME=docker, has it
picked already, so the reporter need not choose.
Step 5 of the Docker proposal. docker/surfsense/ builds one image from the
repository root: the backend's venv, the SPA built for /api, opencode, the
bundled embedding, parser and voice-activity models, Node, Caddy and tini,
run as a non-root user over a /data volume. Compose publishes it on
127.0.0.1:4929, apart from the legacy stack's 3929 so both can run while
data moves over.
The headless entry starts Caddy when a Caddyfile is given; Caddy is the one
listener, refuses any host but localhost until a password exists, keeps the
workers' internal routes in, and forwards the sign-in callbacks. Plugin and
ChatGPT sign-ins send the browser back through the published port
(SURFSENSE_LOCAL_PUBLIC_PORT), ChatGPT's listener on a fixed port Caddy
forwards to. In the container the Claude dialog gives the docker compose
commands that install and sign in Claude Code there.
The self-hosted stack lives on the legacy branch, released as
legacy-v0.0.41. Delete surfsense_backend/, surfsense_mcp/, docker/
except keygen/ (production's Keygen CE images), the root VERSION and
scripts/bump-version.{sh,ps1}. surfsense_web/ stays until the website
split.
Lockfile-only update of three transitive dependencies of the Remotion
render sandbox (docker/sandbox/remotion). All new versions sit inside the
ranges their parents already declare, so package.json is unchanged and no
overrides are needed.
fast-uri 3.1.5 -> 3.1.8 (via webpack > schema-utils > ajv)
- GHSA-hrr3-gc8f-f4qj, GHSA-qw65-cvwx-89v3, GHSA-5jgf-p345-68v8,
GHSA-f65p-4m7j-42xc, GHSA-fph4-wmhf-6fwf, GHSA-jqff-g426-hqxp
js-yaml 4.3.1 -> 4.3.2 (via @svgr/core > cosmiconfig)
- GHSA-2883-xcg3-v3hh
undici 6.28.0 -> 6.28.1 (via markmap-lib > cheerio)
- GHSA-r53p-7pc4-xj5r, GHSA-rfgv-xxqx-mfg5, GHSA-3wwx-pv8p-q78v
undici is held at 6.28.1 rather than 6.29.0, which was published less
than seven days ago. Regenerated with npm 11 so the existing libc and
peer metadata in the lockfile is kept; lockfileVersion stays 3.
Nothing deferred.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The trial check refused 20 hand-picked domains by exact match, so trial
spam walked around it with any other throwaway provider, any subdomain
of a listed one, or a punycode spelling.
Refuse the community disposable-email-domains list (~9.2k domains, CC0),
installed as its official PyPI package and pinned in uv.lock, plus the
three hand-picked domains it lacks. Match every parent domain short of
the TLD, as upstream requires, and look up the punycode form, since
EmailStr hands the route Unicode. Purchases and resends are unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrM98FAv8YT3boAVJpf6PW
Comments, docstrings and .env.example notes that cited moved or
deleted plan files now cite their new homes under docs/.
The three llama.cpp checksums in fetch-llamacpp.mjs and a URL-format
example in the backend config are marked `pragma: allowlist secret`.
Touching those files puts them in front of detect-secrets, and both
are false positives.
Co-authored-by: Cursor <cursoragent@cursor.com>
Clear the SEO skill dump and shipped-phase plans so they can be rebuilt later. Leave Azure compose (SearxNG, sandbox, zero-cache) and license/export contracts untouched.
A trial is handed to an email address, not to an installation, so the email now opens with an install link built from NEXT_FRONTEND_URL. Deployments without a portal keep the original steps rather than printing a dead link.
Trial length moves to 30 days so the week before the first plugin ships does not eat half of it. The number is read from LICENSE_TRIAL_DAYS everywhere, including the subject line and the pricing copy, which previously hardcoded 14.
policies is {name: id}, so joining it yielded the names -- which the caller
already knew -- and dropped the ids, which are what KEYGEN_POLICY_* has to be
set to. Recovering them meant minting a second admin token and querying, and
the script's own warning is that re-running creates duplicate policies.
Coolify has no field for a container command, so the worker needs a Dockerfile to set CMD. The web file keeps both processes pinned to the same tag in one place.
Coolify has no field for a container command, so the worker needs a Dockerfile to set CMD. The web file keeps both processes pinned to the same tag in one place.
- Revised the license file format to reflect the use of self-hosted Keygen CE.
- Clarified the configuration requirements for Keygen integration.
- Updated portal documentation to specify the handling of license routes and delivery processes.
- Added notes on the implications of self-hosting Keygen, including testing and contract management.
LICENSE_MAIL_TRANSPORT selects how mail leaves the process (null, console, smtp), never which vendor delivers it: every provider exposes SMTP, so changing company means editing the connection strings and leaving this alone.