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.
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.
The SPA gets a favicon, the app's icon at 16, 32 and 48 px, so the Docker
image's tab shows SurfSense instead of a globe. Vite's dev server swaps in the
DEV icon and the title SurfSense Dev, as Electron's dev build names itself, so
the dev stack's tab is told apart from an install's.
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.
OpenAI and the plugin host return the browser to 127.0.0.1, and a browser
signed in on localhost holds no cookie for that name, so the access
service answered 401 and the sign-in never finished. /callback and
/plugins/oauth/callback are guarded by their own state and PKCE, so only
/api/ needs the session now; the host checks still apply. With nothing
cross-site needing the cookie, it is SameSite=Strict again.
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.
Outside a secure context, as the Docker image is from another device over
plain HTTP, the browser offers no microphone, and the strip said only that
it couldn't start. It reads window.isSecureContext, a browser rule rather
than a Docker one.
Nothing in the image reports them, so the API there neither counts
activity nor serves /usage-statistics. Before, only Electron's absence
kept the image from reporting.
Five places compared VITE_SURFSENSE_RUNTIME to "docker" themselves. They
read IN_CONTAINER now, and the variable is typed "docker" | undefined, so
a misspelt comparison fails typecheck. ADR 0075 records the rule: what
only Electron can do is gated on its bridge, what the Docker build does
differently on this flag, and the backend on in_container().
An upload's file lives in the container, where no app on the user's
computer can open it or show its folder. Its menu offers Download, from
GET .../documents/{id}/original, and the preview's button reads Download
file. A click previews a PDF and does nothing on other files.
In the Docker image the API trusts the browser's Host, which Caddy and
the access service have vetted, and never on the desktop. A LAN name
registers dynamically with its own callback; the public domain signs in
as the instance's own client document. The session cookie is Lax, so the
provider's redirect back carries it past Caddy.
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.
ADR 0074 replaces step 10's basic_auth: the browser's dialog has no page,
no sign-out and no place to set the password but .env. Caddy stays the one
door and asks an access service in the headless entry about every request
through forward_auth; the service keeps one scrypt-hashed password and
signed session cookies, slows guessing, and without a password still
answers only the host, by its Host header and its client address. The
proposal's access table, files and step 10 follow.
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.
The same tag starts Release Docker, which pushes X.Y.Z, and publishing
moves latest and X.Y to it. The runbook now waits for its image job before
publishing, checks latest afterwards, makes the GHCR package public on the
first release, and names the image's own dry run. A failed image job does
not hold up the desktop.
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.
A pull request builds the image, and runs the backend in it, only when it
touches the Dockerfile, Caddy, compose, headless, sidecars, opencode's
staging, the bundled models' downloads, a dependency file, the frontend's
build config, VERSION or the plugin bundle. A release's pull request
carries the VERSION bump, so it always does; one of docs or www does not.
Every dev push and manual run still runs every job, so ordinary code that
breaks the image shows on the merge into dev.
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.
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.
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 Docker image runs the backend from a venv, which `sys.frozen` calls a
checkout, so it would trust the repository's unsigned plugin list and
sign plugins in with a loopback callback. SURFSENSE_LOCAL_SIGNED_REGISTRY,
which the headless entry sets, makes it read the signed list as a
packaged app does.
The Docker proposal no longer says the headless entry turns previews
off: with no desktop app polling, the API already refuses one at once.
The pinned staging of llama.cpp, sd.cpp, audio.cpp and opencode, with
the helpers they share, leaves electron/scripts/ so the desktop build and
the Docker image stage from one place (ADR 0072). Each script still
stages into electron/<runtime>/, so electron-builder, the build artifacts
and the packaged app do not change; the workflows and docs follow the
new paths.
The electron package now runs runtimes/'s tests through a quoted glob,
which reaches audiocpp/espeak/: its 21 tests were skipped before, since
the shell expanded the unquoted scripts/**/*.test.mjs one level deep.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sidecars/ holds what electron/src/main/sidecars/ held, with net.ts, the
session log's Node-only files and check-sidecars.mjs, so the desktop and
the coming headless entry boot from one place (ADR 0072). Renames only:
the electron package runs and typechecks it, and nothing it does changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every OAuth callback returns to the origin the user's browser is on.
Plugins, Notion included, try a CIMD client, then DCR, then the user's
own app, then paste-back; ChatGPT follows the rule as far as OpenAI's
client allows. Access from another machine takes HTTPS. Closes the
proposal's last open question.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The user installs Claude Code in the container and signs in there; HOME
on /data keeps both across updates. The Claude dialog shows the container's
commands when the API reports it runs in Docker. Closes the proposal's
question about the claude CLI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Docker proposal maps the container's networks and says why Caddy is
the one listener. The GPU proposal closes its driver question: -cuda
needs 570.26 on Linux and 570.65 on Windows, kept beside the llama.cpp
pin and enforced by the installer, NVIDIA_REQUIRE_CUDA and the GPU check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Docker proposal moves the supervisor and runtime staging out of
electron/ into sidecars/ and runtimes/, adds the headless entry in
headless/, and puts the image in docker/surfsense/ built from the repo
root. latest moves when the GitHub release is published. The GPU image is
-cuda on CUDA 12.8, with -cuda13 later for arm64, and each runtime keeps
one source shared with the desktop. ADR 0072 and 0073 record the layout
and the variants; 0073 supersedes 0035's CUDA variant names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Drop the surfsense_backend ruff and bandit hooks, its proprietary
path from code-quality.yml, and the legacy CODEOWNERS, .gitignore and
launch.json entries.
- Repoint the fastapi skill link at surfsense_local/backend/.venv.
- AGENTS.md, CONTRIBUTING.md and SECURITY.md name the legacy branch for
the self-hosted stack; drop the legacy commands, tests and boundaries.
- Start the SMTP contract test's Mailpit with docker run.
- Repoint dead doc links at the legacy branch, or at a permalink where
legacy removed the file too.
- The legacy-split proposal records the branch as cut and the deletion
as its own pull request before the website split.
docker-build.yml on main published `latest` and `0.0.40` for the
self-hosted images, and every desktop release merge started it, so the
next one would have replaced the 0.0.41 images released from `legacy`.
The self-hosted stack now builds, tests and releases from the `legacy`
branch only.
- Delete docker-build.yml and docker-tests.yml.
- Drop the release-runbook paragraph about the image build on main.
- Point the docs' links to docker-build.yml at the legacy branch, and
update the comments and CONTRIBUTING lines that named either workflow.
- chat.md, overview.md: how a streamed token renders only the live reply
(runSummaries, the 32 ms notice gap, LiveThreadRuntime, the bounded
highlighter, memoized panels) and that the columns resize.
- model-capabilities.md, agent.md, data-model.md, 07-dev-setup.md: the two
chat modes, their gates and defaults, where a pick is stored, the 0-id fix,
and how to re-run the OpenRouter sweep and regenerate capabilities.json.
- RELEASE.md: the steps 2.2.0 was released by, from the bump (with its
PowerShell form) to the tag on the merge, the Known issues, an optional
update check and publishing with --latest; dry runs are signed.
- 06-repo-readme.md, AGENTS.md, README.md's comment: the README's structure,
how to change it and its translations, re-checking the format table, and
replacing the demo video.
- Stale statements fixed (the Apple agreement, opencode never shipped, three
languages, Studio's exec(), proposal statuses, the roadmap), and each gap
still open after the release in its doc's Known gaps, linked to #2219-#2230.
Each step depends on the one before it and leaves something that runs:
groundwork that keeps the desktop app working, the first CPU container
and its CI, one local runtime at a time, access beyond localhost, then
the GPU images. Docs and AGENTS.md are left to the maintainers once
the work ships.
The desktop app's stack, without Electron, ships as one container in
docker/local/: a supervisor shared with Electron runs the API, the
workers and the model servers on loopback, and Caddy is the only
listener. Without a password it answers only localhost; with one it
uses basic auth, over HTTPS when a domain is set. Images come as CPU,
-cuda (12.8) and -cuda126 (12.6) on amd64 and arm64. The license is the
desktop's offline file. The legacy stack in docker/ is not changed.
ADR 0035 records the decision and supersedes parts of ADR 0004 (Docker
out of scope, the loopback rule) and ADR 0012 (no CUDA), for Docker
only.