Operational runbook for the Nextcloud “Drive” stack (Nextcloud + Postgres + Redis + OnlyOffice + tsauth-proxy + drive-mcp) and the code-server browser IDE, both on telep-mainframe. This is the closing deliverable of 2026-08-31-nextcloud-drive-tailscale-plan (design of record: 2026-08-31-nextcloud-drive-tailscale-spec). Everything below is verified state as of 2026-08-31 — a future operator or agent should be able to act from this page without re-deriving anything.
The host this all runs on has a known CPU hardware fault
Every service here is tailscale servetailnet-only. Do not add tailscale funnel to any of these nodes.
This is absolute for code-server, which runs with --auth none: tailnet membership IS the credential. A Funnel on code would publish an unauthenticated remote shell + file browser to the public internet.
Verify at any time with serve status — every mount must print (tailnet only).
Architecture at a glance
The per-service Tailscale sidecar pattern (the house convention — see tailnet-service-exposure-convention): each service gets its own userspace tailscaled, its own tailnet hostname, and a tailscale serve mount that fronts a loopback-only port.
tailnet client (identified by Tailscale)
│ HTTPS, tailnet-only, LetsEncrypt cert for <svc>.taild4189d.ts.net
▼
tailscaled-<svc>.service --tun=userspace-networking
socket /run/tailscale-<svc>/tailscaled.sock
statedir /var/lib/tailscale-<svc>
--port=0
│ tailscale serve --bg --https=<port> http://127.0.0.1:<loopback>
▼
127.0.0.1:<loopback> the app. NEVER bound to LAN or tailnet IP.
Each sidecar is a separate systemd unit and a separate tailnet device, so ACLs, sharing and node lifecycle are per-service.
The KB node’s hostname is knowledgebase but its unit/socket/statedir are named kb (/run/tailscale-kb/…). Every other node here matches. If --socket=/run/tailscale-knowledgebase/… errors with “no such file or directory”, that’s why.
For Agents
telep-mainframe cannot reach its own tailnet-only HTTPS URLs — the same-host userspace-tailnet hairpin times out. That is expected and is NOT a fault. Prove things over loopback (127.0.0.1:<port>) instead; the tailnet hop can only be confirmed from a separate in-ACL device. See Verification checklist.
Port map
Everything binds loopback only; the tailnet ACL is the perimeter. There is no LAN listener for any of these.
These backends now bind 127.0.0.2, NOT 127.0.0.1 — do not "correct" them back
A systemic finding this session: tailscale serve does not close the userspace-networking raw forwarder, which forwards the node’s tailnet IP to 127.0.0.1:<port> as plaintext, bypassing the TLS front door. On drive/code this had exposed raw SSO-bypassing Nextcloud (:11000) and an unauthenticated code-server shell (:8888) in cleartext to the whole tailnet. The fix moved these backends to 127.0.0.2 (the forwarder only targets 127.0.0.1, so the plaintext door goes dead while serve, re-pointed at 127.0.0.2, keeps working): Nextcloud 127.0.0.2:11000, tsauth-proxy 127.0.0.2:11001, OnlyOffice 127.0.0.2:11002, drive-mcp 127.0.0.2:9100, code-server 127.0.0.2:8888. Any NEW sidecar must bind 127.0.0.2 from the start. Test closure by IPv4 (100.x), never by hostname (MagicDNS resolves IPv6 first, which the forwarder ignores → false “closed”). Full detail, the box-wide sweep, and the still-open cad:9875 XML-RPC surface: 2026-08-31-tailnet-plaintext-port-hardening. The loopback ports in the table below are otherwise unchanged; wherever it reads 127.0.0.1, the SSO/no-auth backends listed here are now 127.0.0.2.
Loopback
Owner
Fronted by
Notes
127.0.0.1:11000
Nextcloud apache (docker-proxy → container :80)
(none directly)
Deliberately not served. Reachable only via tsauth-proxy.
127.0.0.1:11001
tsauth-proxy (Go, systemd, runs as root)
https://drive.taild4189d.ts.net
Identity injector. See .
127.0.0.1:11002
OnlyOffice DocumentServer container
https://drive.taild4189d.ts.net:8443
Browser-facing editor origin.
127.0.0.1:9100
drive-mcp — WebDAV-backed MCP server
https://drive.taild4189d.ts.net:8444/mcp
Served from the existing drive sidecar — no new tailnet node. See .
443 (Nextcloud via tsauth-proxy) · 8443 (OnlyOffice DocumentServer) · 8444 (drive-mcp). All three are (tailnet only). Check them together:
sudo -n tailscale --socket=/run/tailscale-drive/tailscaled.sock serve status
Adjacent mounts on the same cad node, from the FreeCAD workstation build (listed so nobody reuses their ports):
Loopback
Owner
Fronted by
127.0.0.1:3080
FreeCAD noVNC
https://cad.taild4189d.ts.net
127.0.0.1:8085
exports-http nginx autoindex
https://cad.taild4189d.ts.net:8080
127.0.0.1:9876
FreeCAD MCP
https://cad.taild4189d.ts.net:8443
127.0.0.1:8090 is NOT O3DV
Loopback 8090 is occupied by an unrelated local python3 listener. O3DV’s 8090 is a tailscale serve HTTPS port on the cad node that proxies to loopback 8087. Don’t pick 8090 for a new service and don’t curl 127.0.0.1:8090 expecting the viewer.
Identity
Nextcloud has no password prompt for tailnet users. Identity comes from Tailscale itself:
tailnet client
→ drive node, tailscale serve :443 (TLS) sets X-Forwarded-For = client tailnet IP
→ 127.0.0.1:11001 tsauth-proxy
· reads X-Forwarded-For
· tailscale --socket=/run/tailscale-drive/tailscaled.sock whois --json <ip>
· resolves UserProfile.LoginName (e.g. ledererandras2004@gmail.com)
· OR, for a TAGGED device, maps a configured tag → a Nextcloud user (see below)
· STRIPS any inbound Remote-User / Tailscale-User-Login, then sets Remote-User: <login>
→ 127.0.0.1:11000 Nextcloud apache sees $_SERVER['HTTP_REMOTE_USER']
→ user_saml in "environment-variable" mode → auto-provisions + logs the user in
App: user_saml 8.3.0, type = environment-variable.
Attribute mapping lives in the provider DB record, provider id 1 — not in appconfig:
occ saml:config:set 1 --general-uid_mapping=HTTP_REMOTE_USER --saml-attribute-mapping-email_mapping=HTTP_REMOTE_USER --saml-attribute-mapping-displayName_mapping=HTTP_REMOTE_USER
Break-glass local admin is retained: https://drive.taild4189d.ts.net/index.php/login?direct=1, password in /home/levander/nextcloud/.env.
trusted_proxies = 127.0.0.1, 172.23.0.1, 172.23.0.0/16 (docker bridge gw + subnet, the tsauth-proxy source as seen by the container).
Why this is loopback-safe
Remote-User is trusted because only tailscale serve can reach the proxy: tsauth-proxy listens on 127.0.0.1:11001 and nothing else on the box fronts it, and the proxy unconditionally strips any client-supplied Remote-User before setting its own. The trust boundary is “can you open a socket on this host’s loopback” — i.e. a local root/shell compromise, which already owns the box.
This is why there is NO LAN vhost
A Caddy *.telep.lan entry point for Drive was deliberately skipped. A LAN vhost would arrive without a tailnet source IP, so whois yields nothing and the identity layer is bypassed — a LAN client would either get no identity or (worse) be able to hand-craft Remote-User. Drive is reachable only over the tailnet, on purpose. Do not “helpfully” add a LAN vhost later.
Tag identity — mapping a tagged device to a Nextcloud user
Tagged Tailscale devices have no user for whois to resolve. tsauth-proxy can map a configured tag to a Nextcloud username via TS_TAG_USERS in /etc/tsauth-proxy.env (an env file, chmod 600 — read it on the box, never here).
Security properties worth recording, because they are what makes this acceptable:
Property
Behaviour
Explicit allowlist only
Only tags named in the config map to a user. An unlisted tag resolves to nothing.
tag:telep maps to NOTHING
The house-wide tag deliberately grants no Nextcloud identity. Tagging a device tag:telep does not silently give it Drive access.
Header stripping is unconditional
Client-supplied Remote-User / Tailscale-User-Login are stripped after identity resolution, so a tagged client cannot smuggle a different user.
Fails closed
No resolvable identity → no Remote-User header → Nextcloud does not auto-login. It does not fall back to a default user.
Tagging a device REPLACES its personal Tailscale identity
This is a Tailscale property, not ours. Once a device is tagged, whois returns the tag and not the human who owns it — so any other tailnet service that identifies users by LoginName will stop recognising that device. Do not tag a device you use interactively as yourself unless you have accounted for every service that whois-identifies it.
tsidp was the plan's primary and was NOT used
tsidp builds fine (/home/levander/gobin/tsidp) but needs either its own owner-authed tailnet node or -use-local-tailscaled (which collides with the host node’s already-bound :443). The whois→header fallback was taken per the plan’s “don’t thrash” rule. If a standards-based OIDC IdP is wanted later, tsidp needs its own node.
External storage
Two external storages are mounted into Drive. They are managed completely differently — one is a read/write working area, the other is a strictly read-only window onto the Windows install.
Storage id
Drive path
Nextcloud compose bind (host → container)
datadir
Mode
1
/CAD
/home/levander/freecad/exports → /mnt/cad
/mnt/cad
rw — the FreeCAD/CAD-designer export pipeline writes here
2
/windows
/mnt/win → /mnt/windows:ro
/mnt/windows/Users
readonly: "1" — read-only, enforced at four independent layers
Host says win, container says windows — they are not typos of each other
The NTFS volume is mounted on the host at /mnt/win; the Nextcloud container sees it at /mnt/windows. occ output and docker compose exec paths use the container form; findmnt / fstab use the host form. Confirm with occ files_external:list.
Nextcloud binds the WHOLE C: drive and narrows to Users in config — code-server narrows in the bind
This asymmetry is deliberate and is the single most useful thing to know about this storage.
Bind
Where the narrowing to Users happens
Nextcloud
/mnt/win:/mnt/windows:ro — the entire Windows C: drive
at the Nextcloud layer, via storage id 2’s datadir: /mnt/windows/Users
code-server
/mnt/win/Users:/home/coder/windows:ro — only Users
in the bind itself; the container never sees the rest of C:
Consequence — widening Drive to the full C: drive is ONE command, no compose edit, no restart:
The bind already covers it. Do not conclude from the datadir alone that compose needs touching. (Narrowing to a different subtree is the same command with a different path.) code-server is the opposite case: changing its view of the Windows disk does require a compose edit, because its bind is the narrowing.
Verify the live bind rather than trusting either table:
The Windows NTFS volume is exposed so files can be found and read from Drive, code-server and MCP clients. It must never be writable: a stray write into a live Windows install’s Users tree is unrecoverable, and NTFS-under-ntfs3 write support is not something to bet a Windows install on.
#
Layer
Mechanism
1
Kernel mount
/etc/fstab mounts /mnt/win with ntfs3 and the ro option
2
Docker bind
both consumers bind :ro — Nextcloud /mnt/win:/mnt/windows:ro, code-server /mnt/win/Users:/home/coder/windows:ro
3
Nextcloud
the storage is configured readonly=1, applied via a PermissionsMask storage wrapper — Nextcloud masks the permission bits itself, so the UI hides write/rename/delete rather than offering them and failing
4
code-server
files.readonlyInclude marks the path read-only in the editor, so VS Code refuses the save before it reaches the (read-only) mount
Defence in depth is deliberate: layer 3 is the only one the user sees, layers 1–2 are the ones that actually hold if 3 is misconfigured, and layer 4 stops the most likely accidental write (someone editing a config file in the IDE).
/etc/fstab mounts /mnt/winby UUID — never by device node
The two NVMe device nodes swap across boots: what is nvme0n1p2 on one boot can be nvme1n1p2 on the next. A device-node mount would, sooner or later, mount the wrong disk at /mnt/win — which on this box means mounting (part of) the LUKS/LVM OS drive’s neighbour instead of the Windows volume, and exposing the wrong tree to Drive, code-server and MCP.
If you ever edit this fstab line, keep it UUID=. Verify with lsblk -o NAME,UUID,MOUNTPOINT and findmnt /mnt/win after any disk change.
filebrowser is retired — /windows replaced it
filebrowser was the only other door onto the same /mnt/win data. Leaving it running would have meant two independently-configured read-only enforcements on one dataset. The container is stopped, its volumes are preserved (so it can be brought back), and its tailscale serve :8445 mount has been removed. If something still points at :8445, that is the reason it 404s.
Fires every 3 minutes (OnBootSec=2min, OnUnitActiveSec=3min, Persistent=true). nextcloud-scan.service runs as root (so the normalization steps have privilege) and only the scan drops to www-data:
Order matters: normalize data dir → normalize exports group → exports g+rw → scan home dirs → scan /CAD. find … -exec … {} + exits 0 when nothing matches, so an ExecStartPre never spuriously fails the unit.
The scan MUST NOT be allowed to walk /windows
A naive files:scan --all now includes the /windows external storage — a 123 000-entry NTFS tree. Measured: it exceeded 10 minutes and never completed, on a 3-minute timer. That means overlapping scans, a permanently-busy box, and — given the host’s CPU fault — sustained load that can panic the machine.
The fix is the two-ExecStart form above: --home-only restricts the sweep to users’ own home storages (excluding all external mounts), and a targeted --path= scan picks up /CAD, the only external storage that actually changes. Measured cost of the current form: 0.83 s./windows is read-only and effectively static, so it is scanned on demand only.
If you ever “simplify” this back to files:scan --all, you will reintroduce a >10-minute job on a 3-minute timer.
Why the two shares are normalized DIFFERENTLY
Share
Host path
Action
Why
Nextcloud data
/home/levander/nextcloud/data
chown 33:33 — owner AND group taken
Nothing but Nextcloud writes here. www-data (uid 33) must fully own the tree to rename/edit/delete from the web UI.
CAD exports
/home/levander/freecad/exports
chgrp www-data + chmod g+rw — owner PRESERVED
The FreeCAD/CAD-designer export pipeline writes here as levander and must keep its owner-write bit. Taking the owner would break that pipeline. Management from the web UI is granted via the group instead: dir is setgid 2775 group www-data, files g+rw → www-data can rewrite files and unlink them through the directory’s group-write, regardless of who owns each file. other r-x/r-- is preserved so O3DV’s read-only consumer is untouched.
Up to a 3-minute lag before a Taildrive write is editable from the web
A file written over Taildrive lands root:root immediately. Until the next timer fire (≤3 min) Nextcloud can index and read it but cannot rewrite/delete it. After the fire it is normalized and fully manageable. This is by design — an inotify/.path unit would close the gap but was explicitly out of scope.
Per-cycle cost
Both find sweeps plus the two scoped scans run every 3 min, at 0.83 s measured. If the data dir grows large, scope the finds to hot subpaths too rather than lengthening the interval.
netconsole-target.service (host, enabled) → netconsole-rx on telep-router
ships kernel messages off-box over UDP; the only thing that captures the MCE panic. Log: telep-router:/tmp/netconsole/kmsg.log — RAM-backed, copy it off first thing after a crash
10 s samples, fsync’d per line, ~10 days; tags the suspect core as SUSPECT_core16_cpu8_9=
/etc/sysctl.d/99-crash-capture.conf
kernel.printk = 5 4 1 7 — the box boots quiet (console_loglevel 4), which was silently dropping every KERN_WARNING including lockup and hung-task warnings
# all three of the box's own added units should report "enabled"/usr/bin/ssh levander@telep-mainframe \ 'systemctl is-enabled drive-mcp.service netconsole-target.service thermalwatch.service'
/sys/fs/pstore will be EMPTY after a panic — that is expected here
efi_pstore_write runs in <#MC> context where the FPU is unavailable, so the EFI variable write always fails for this failure mode. Don’t chase it.
Clicking a file in Drive routes to a purpose-built viewer/editor rather than downloading. The click-default routing table (by lowest-order enabled action carrying a default):
Matches
Click-default opens in
.stl.3mf.obj.gltf.glb.ply.step.stp
Online3DViewer on the cad node
.FCStdunder /CAD
FreeCAD GUI (via the freecad-open shim — see below)
.csv.docx (+ the office set)
OnlyOffice (native defFormats, not a custom action)
source/code files (.py.js.sh.html.c.json.yaml … — anything the text viewer claims alone, minus prose)
VS Code (code-server)
.md.txt (prose)
Nextcloud Text
.pdf, images
Viewer (Nextcloud’s built-in)
everything else (unknown ext, files under code-server binds)
VS Code — the fallback
shared-in / unmappable files
Download (the container can’t reach them)
always present, always last
Download (order: 1000)
"Open with" is now UNIVERSAL — offered on every file, across every type
Owner feedback drove two changes to the cadviewer-open-with submenu: (1) it appears on every file (presence rule: any non-folder node), not only files with ≥2 handlers; (2) the type-preference gating was removed, keeping only hard reachability gates. So OnlyOffice, Online3DViewer and Download are offered on every file regardless of type (each still delegates to the live action, so it can never offer to open something the underlying handler would refuse). VS Code and FreeCAD stay reachability-gated — not by taste but because their containers literally cannot ingest the file otherwise: VS Code needs the file under a code-server bind (/CAD, /windows, or a Drive-home mount); FreeCAD needs it under /CADand an importable format (.FCStd/.step/.stp/.iges/.igs/.brep). Download always sorts last. Folders get no submenu.
The whole routing lives in cadviewer-main.js and is proven per-change by a Node isolation harness (harness3.js) that transcribes NC 34’s real FileEntryMixin selection logic and asserts click-defaults are byte-stable across edits — because there is no in-ACL browser on this box to click from. Each ship bumps occ config:app:set theming cachebuster (now at 9), so clients need one hard refresh to pick up the new ?v=.
OnlyOffice for .csv/.docx is native config, NOT a custom action
The office formats are claimed via the OnlyOffice connector’s own defFormats configuration. No cadviewer action was written for them. Do not add one — you would end up with two actions competing for the same extension and a non-deterministic default.
The NC 34 file-action registry has a silent-failure mode — read the dedicated note
Registration on NC 34 fails silently if you use the pre-34 global (_nc_fileactions) or positional callbacks; the built-in download wins and the file downloads instead of opening. The real registry, the context-object callback contract, the ordering rules and the cachebuster requirement are all in 2026-08-31-nextcloud-34-custom-file-action-registration — not repeated here.
cadviewer is now a MISNOMER — it handles ALL file actions, not just CAD
The app started as the .stl → O3DV shim and kept the name while growing into the whole routing table above. It was deliberately not renamed: a Nextcloud app id is baked into the install path (custom_apps/cadviewer), the enabled-apps config, and every served asset URL (/custom_apps/cadviewer/js/…). Renaming means a reinstall plus a cachebuster bump for zero functional gain. Expect to find non-CAD logic in an app called cadviewer — that is correct, not a mistake.
FreeCAD open-from-Drive — .FCStd opens in the FreeCAD GUI
Clicking a .FCStd under /CAD (or choosing FreeCAD from any importable file’s Open-with) opens it in the shared FreeCAD GUI on the cad node. The path:
Drive click -> cadviewer-open-freecad -> https://cad.taild4189d.ts.net:8091/?f=<path-under-/CAD>
-> freecad-open shim (127.0.0.1:8094, freecad-open.service, User=levander, ProtectSystem=strict)
-> validates path (traversal rejected, only /CAD reachable)
-> FreeCAD XML-RPC execute_code on 127.0.0.1:9875 (the FreeCADMCP addon)
.FCStd -> FreeCAD.openDocument() .step/.stp/.iges/.igs/.brep -> Part.insert()
-> 302 to https://cad.taild4189d.ts.net/ (the noVNC desktop)
FreeCAD open gotchas — three ways it silently breaks
execute_code_async SEGFAULTS FreeCAD when it touches documents (it runs on a plain background thread). The shim uses execute_code, which runs on the GUI thread. Never use the async form to open a document.
A modal dialog blocks ALL opens. FreeCAD is a single shared session; process_gui_tasks defers while any modal/popup/held-mouse exists. A stuck “Document Recovery” modal made every open return GUI dispatch timed out after 90s while get_rpc_status still said healthy. If open-in-FreeCAD stops working, check for a modal dialog first (dismiss via docker exec -u abc -e DISPLAY=:1 freecad xdotool …).
FreeCAD is single-user / single-session — every click opens the document in the one shared desktop everyone sees. No per-user isolation. Only /CAD files are reachable (it is the only bind into the FreeCAD container). Served on the existing cad node via serve --https=8091→127.0.0.1:8094; the shim’s :8094 bind was moved to 127.0.0.2 by the hardening sweep. cad:9875 (FreeCAD XML-RPC) is a remote-code surface still open plaintext on the tailnet — flagged there for follow-up.
Online3DViewer is now Drive-wide (not just /CAD)
O3DV can open 3D files anywhere in the admin Drive, not only /CAD. Two nginx locations on the o3dv container serve read-only from /home/levander/nextcloud/data/admin/files:/drive:ro:
location ~* ^/drive/.+\.(stl|step|stp|3mf|obj|gltf|glb|ply|fbx|dae|3ds|off|wrl|brep|igs|iges|fcstd|amf|3dm|ifc)$→ serves the file (disable_symlinks on).
location /drive/→403 (every non-3D file and all directory listing blocked; autoindex off).
URL scheme: a /CAD file stays #model=/exports/<basename> (unchanged); any other admin-Drive file at /Foo/bar.stl→#model=/drive/Foo/bar.stl (each path segment URL-encoded). Traversal is rejected (nginx normalizes ../ out before matching; encoded %2e%2e→ 400, 0 bytes leaked).
Those 3D files are readable on the no-auth cad node
cad’s O3DV origin is tailnet-only but has no auth (any tailnet peer that reaches :8090, including a tag:telep-user collaborator, can read it). The Drive-wide change makes any admin-owned 3D-extension file readable there, read-only, path-addressable only (the peer must know the path). NOT exposed: /windows (deliberately not mounted), non-admin/shared/external-storage files, and anything non-3D (→ 403).
Language and defaults
Setting
Value
default_language
hu
default_locale
hu_HU
defaultapp
files
force_language
deliberately unset
force_language is left unset on purpose: it would lock every user to Hungarian and remove the per-user language switcher. New users default to Hungarian; anyone can still switch. Set via occ config:system:set <key> --value <v>.
drive-mcp — files over MCP
A WebDAV-backed MCP server exposing Drive’s files to agent clients.
Endpoint
https://drive.taild4189d.ts.net:8444/mcp
Loopback
127.0.0.1:9100
Sidecar
the existing drive node — deliberately not a new tailnet node
Backend
Nextcloud WebDAV
Identity
a single shared identity — every MCP client acts as the same Nextcloud user
Reusing the drive sidecar keeps the file service and its MCP face on one tailnet name and one ACL entry, at the cost of a third serve port (see the port map). A separate node would have meant a second device to approve, ACL, and keep alive for no isolation benefit — the MCP server already has full Drive access by design.
FastMCP behind tailscale serve returns 421 Invalid Host header unless allowed_hosts is set
tailscale serve forwards the originalHost header, and mcp ≥ 1.29 turns on DNS-rebinding protection with an empty allowed_hosts — so only 127.0.0.1 passes. The symptom is brutal to diagnose: loopback works perfectly, every tailnet request gets a bare 421 that names no layer, so it reads as a proxy or auth failure rather than an application setting.
Fix: pass TransportSecuritySettings(allowed_hosts=[...]) to FastMCP(...) including the tailnet hostname. Identical trap and identical fix as 2026-08-31-telep-kb-mcp-server — if a new MCP server on this box 421s, this is why.
# loopback proof (should answer; tailnet hop needs an in-ACL device)curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9100/mcpsudo -n tailscale --socket=/run/tailscale-drive/tailscaled.sock serve status | grep 8444
Common operations
All commands run from a workstation over SSH. Always use /usr/bin/ssh (the ssh shell alias is broken on the Mac). Use sudo -n on the box.
drive-mcp’s WebDAV credentials for its shared Nextcloud identity. 0600, owner levander:levander — note this is in /etc, not alongside a compose dir like the other two
Compose files reference these only as ${VAR} — no literal secret is in any tracked config.
the working area; editing exports and scripts from the IDE is the point
/mnt/win/Users
/home/coder/windows
ro
fourth read-only layer; files.readonlyInclude also marks it read-only inside VS Code. Narrowing to Users happens here, unlike Nextcloud
…/nextcloud/data/<user>/files
/home/coder/drive/<user>
ro
one bind per account, not one tree — see below
Drive-home is bound PER USER — a new Nextcloud account is invisible in the IDE until you add its line
There is no single …/nextcloud/data:/home/coder/drive mount. Each account gets its own line. If someone logs into Drive for the first time and then can’t find their files in code-server, this is why: add a bind for …/nextcloud/data/<user>/files and docker compose up -d.
Quote any path containing @ — the ledererandras2004@gmail.com line is quoted for exactly this reason. An unquoted @ in a compose bind is a parse hazard; copy the quoting style of the existing line.
Drive-home is read-only ON PURPOSE — and reversing it is one compose line
Read-only lets the mount reuse the existing 0644 bits on Nextcloud’s data dir. Making it writable from code-server would require loosening permissions on Nextcloud’s own data directory (or running the container as www-data), which trades a real security property for convenience. Since files are editable from the Drive web UI anyway, read-only in the IDE loses very little.
If it ever needs to be writable: flip :ro → :rw on that user’s bind in /home/levander/code-server/docker-compose.yml, remove the path from files.readonlyInclude, and docker compose up -d. Deliberate, reversible, one line per account.
The code node is tagged tag:telep
code now carries tag:telep, matching cad / knowledgebase / home. This makes the node an infrastructure device rather than a personal one, so its lifecycle and ACLs follow the same rules as the rest of the house services.
Re-authing a tagged node does NOT retrigger device approval
tailscale up on an already-registered node reuses the existing node key, so re-authing to apply or change tags does not create a new device and does not need re-approval. You can safely re-run it.
Pass --hostname=code explicitly or tailscale up resets it to the OS hostname
Without --hostname, the sidecar re-registers under the host’s name (telep-mainframe), the MagicDNS name code.taild4189d.ts.net stops resolving, and the serve mounts land on the wrong name. Always include it:
sudo -n tailscale --socket=/run/tailscale-code/tailscaled.sock up \ --hostname=code --advertise-tags=tag:telep --auth-key=<from /home/levander/code-server/.env>
The same applies to every other sidecar on this box.
OpenSCAD baked into the image
OpenSCAD is available in code-server, made persistent via a Dockerfile layer on the codercom/code-server:latest base (a locally-built code-server-openscad:latest, build: in compose) — baked into the image, so it survives every docker compose up -d recreate. The headless CLI render works (openscad -o out.stl in.scad, this 2021.01 build exports CSG STL without needing xvfb); the GUI preview does not (the Antyos extension, from OpenVSX, launches the GUI app which has no display in the headless container — the CLI .scad→.stl/.off into /CAD is the working path). The image build was pinned taskset -c 0-15,17-23 off the faulty core.
code-server now mounts the ENTIRE home dir — ~/.ssh and .env secrets are exposed to anyone reaching code
A - /home/levander:/home/coder/hostrw bind was added to the code-server compose. Because code-server runs --auth none (tailnet membership IS the credential), the whole home directory — including ~/.ssh, every .env secret file, and every service’s compose/state under /home/levander — is now readable and writable by anyone who can reach the code node. This is a materially larger blast radius than the previous scoped /CAD + Drive-home + /windows mounts.
The mitigation is at the ACL, not the mount: scope tag:telep-user’s (and any collaborator’s) tailnet ACL to exclude the code node. The mount stays as the owner requested; the ACL is what must keep others off it. Revert is one compose line (docker compose up -d) if the full-home mount is ever unwanted.
Gotchas
Taildrive writes land root:root — there is no per-share uid map
Both shares serve as root because tailscaled runs as root, and Tailscale 1.102.2 has no per-share uid mapping. Anything written into drive or cad over Taildrive is created root:root.
The mitigation is nextcloud-scan.timer (see above): its three ExecStartPre steps normalize ownership before each scan. Consequence: a ≤3-minute window where the file is visible/readable in the web UI but not yet editable. Do not “fix” this by running the sidecar as www-data — that’s a much larger change and was rejected.
services.yaml — ALWAYS quote description: values
An unquoted colon-space inside a description is parsed as a YAML mapping and blanks the entire dashboard, not just that tile. Quote every description.
Two more house rules for this file:
Tiles use a loopback siteMonitor (http://127.0.0.1:<port>/...) because homepage runs host-networked — the container shares the host netns, so 127.0.0.1 resolves to the host’s loopback listeners. A tailnet URL there would fail.
No tile uses an icon: field. Adding one would try a dashboard-icons CDN fetch that may not resolve on this tailnet-restricted host; a broken icon looks worse than the consistent default. Match the neighbours.
The live Nextcloud tile (group 💾 Tárolás & Tudás):
code-server has an equivalent tile in 🛠️ Eszközök & Média. After any edit: docker compose -f /home/levander/homepage/docker-compose.yml restart, then confirm with curl -s http://127.0.0.1:3010/api/services.
Connector: jwt_secret = the same 64-hex value, jwt_header = Authorization
DocumentServerUrl = https://drive.taild4189d.ts.net:8443/ — browser-facing, must be the tailnet URL because the user’s browser loads the editor from it
DocumentServerInternalUrl = http://onlyoffice/ and StorageUrl = http://nextcloud/ — container-to-container on the nextcloud_default network
occ config:system:set allow_local_remote_servers --value true is required for those internal http:// URLs to be accepted
Check with occ onlyoffice:documentserver --check → “…is successfully connected”.
Registering a custom file action on NC 34 fails silently if you use the pre-34 global or positional callbacks; the built-in download action wins and the file downloads instead of opening. Full root cause, the live registry, the callback contract and the cachebuster requirement: 2026-08-31-nextcloud-34-custom-file-action-registration. Not repeated here.
code-server has NO authentication
It runs --auth none. Tailnet membership IS the credential. There is no password, no login page, no second factor. The code node’s ACL is the only thing between an attacker and a shell on the box. Consequences:
Never enable Funnel on code.
Never share the code node out to anyone you would not give SSH to.
Keep the container bound to 127.0.0.1:8888; verify after any compose change (see the loopback-only proof below).
Two smaller ones
The code-server named volumes code-server_code-config / code-server_code-local had to be chowned 1000:1000 once (the image lacks ~/.config / ~/.local, so fresh volumes mount root-owned and code-server hits EACCES). A future docker compose down -v deletes them and the chown must be repeated. The coder user is uid/gid 1000 = levander, so container-written workspace files land owned by levander with no remap.
The cadviewer app declares max-version="35" in info.xml. A Nextcloud upgrade past major 35 will disable it until the bound is bumped.
Verification checklist
Provable from the host (SSH only)
# --- Nextcloud core ---curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: drive.taild4189d.ts.net' http://127.0.0.1:11000/status.php # 200curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:11001/status.php # 200 (proxy hop alive)cd /home/levander/nextcloud && docker compose ps # nextcloud, cron, db, redis, onlyoffice all Updocker compose exec -T --user www-data nextcloud php occ status # installed=true, maintenance=falsedocker compose exec -T --user www-data nextcloud php occ setupchecks # target: zero ⚠, zero errors (ℹ items are fine)# --- Identity ---sudo -n systemctl is-active tsauth-proxy.service # activesudo -n tailscale --socket=/run/tailscale-drive/tailscaled.sock whois --json <a-real-tailnet-device-ip> # resolves LoginNamedocker compose exec -T --user www-data nextcloud php occ saml:config:get --providerId 1 # uid/email/displayName all HTTP_REMOTE_USERdocker compose exec -T --user www-data nextcloud php occ user:list # admin + the tailnet login# --- OnlyOffice ---curl -sf http://127.0.0.1:11002/healthcheck # truedocker compose exec -T --user www-data nextcloud php occ onlyoffice:documentserver --check # "successfully connected"# --- External storages ---docker compose exec -T --user www-data nextcloud php occ files_external:list # /CAD rw, /windows readonly=1docker compose exec -T --user www-data nextcloud php occ files_external:verify 1 # /CAD status: ok, code: 0docker compose exec -T --user www-data nextcloud php occ files_external:verify 2 # /windows status: ok, code: 0docker compose exec -T nextcloud ls /mnt/cad | wc -l # matches host exports countfindmnt -no SOURCE,FSTYPE,OPTIONS /mnt/win # HOST: ntfs3, ro — and the fstab entry must be UUID=docker compose exec -T nextcloud sh -c 'touch /mnt/windows/Users/.probe'; echo "exit=$?" # CONTAINER path — MUST fail (read-only)# --- drive-mcp ---curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9100/mcp # server alive on loopbacksudo -n tailscale --socket=/run/tailscale-drive/tailscaled.sock serve status # 443 + 8443 + 8444, all "(tailnet only)"# --- Scan / normalization timer ---sudo -n systemctl list-timers nextcloud-scan.timer --no-pager # enabled + next fire within 3 minsudo -n systemctl start nextcloud-scan.servicesudo -n systemctl show -p Result -p ExecMainStatus nextcloud-scan.service # Result=success ExecMainStatus=0# ownership round-trip: drop a root:root probe into either share, run the service, re-statsudo -n tailscale drive list # drive + cad shares present# --- Sidecars / serve (the NO-FUNNEL check) ---for s in drive cad code kb; do sudo -n tailscale --socket=/run/tailscale-$s/tailscaled.sock serve statusdone# EVERY mount must read "(tailnet only)". Any line mentioning Funnel is an incident.# --- code-server ---curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8888/ # 302 -> ./?folder=/home/coder/workspacecurl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://192.168.1.123:8888/ # 000 (exit 7) = NOT on the LANcurl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://100.115.209.87:8888/ # 000 (exit 7) = NOT on the host tailnet IPsudo -n systemctl is-enabled tailscaled-code.service # enableddocker compose -f /home/levander/code-server/docker-compose.yml ps # runningsudo -n tailscale --socket=/run/tailscale-code/tailscaled.sock status --json | grep -i 'tag:telep' # node is taggeddocker inspect -f '{{range .Mounts}}{{.Source}} -> {{.Destination}} (rw={{.RW}}){{"\n"}}{{end}}' \ $(docker compose -f /home/levander/code-server/docker-compose.yml ps -q)# /home/levander/freecad/exports -> /home/coder/cad (rw=true)# /mnt/win/Users -> /home/coder/windows (rw=false)# …/nextcloud/data/<user>/files -> /home/coder/drive/<user> (rw=false) one line PER account# --- filebrowser is RETIRED (should all be negative) ---docker ps -a --format '{{.Names}} {{.Status}}' | grep -i filebrowser # present but Exited (volumes preserved)curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://127.0.0.1:8445/ # 000 = gone# --- cadviewer file action (server side) ---docker compose exec -T --user www-data nextcloud php occ app:list | grep cadviewer # enabledcurl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:11000/custom_apps/cadviewer/js/cadviewer-main.js # 200# --- Dashboard ---curl -s http://127.0.0.1:3010/api/services | grep -o 'Nextcloud\|Code Server' # both tiles present
Only confirmable from a tailnet device inside the ACL
The host cannot prove these — do not try
telep-mainframe cannot hairpin to its own tailnet-only HTTPS (times out), and neither fk-dev nor telep-router is admitted by the drive/code node ACLs (connect timeout / “Operation not permitted” — identically on ports already proven working over loopback). These require an in-ACL device with a browser.
#
Check
Expected
1
Open https://drive.taild4189d.ts.net
Auto-logged-in as the Tailscale identity, no password prompt
2
Upload/create a .docx and .xlsx, open, edit, close, reopen
OnlyOffice iframe loads from :8443; the change persisted
3
Browse Drive → CAD, hard-refresh once (⌘/Ctrl-Shift-R), click a .stl
New tab → https://cad.taild4189d.ts.net:8090/o3dv/#model=/exports/<name>, model paints. The hard-refresh is required once — the action registers client-side and the Files bundle is cached.
4
Open https://cad.taild4189d.ts.net:8090/
O3DV landing card grid; clicking a card renders the model
5
Open https://code.taild4189d.ts.net
VS Code loads straight into /home/coder/workspace, no auth prompt (expected — see the danger callout)
6
Mount both Taildrive shares in Finder
drive and cad mount; reads work. Writes land root:root until the next timer fire.
7
Load https://home.taild4189d.ts.net
Nextcloud + Code Server tiles render with a green status dot
8
Browse Drive → windows
Tree lists; no New/Upload button, no rename/delete in the row menu (the PermissionsMask wrapper hides them). Reads work.
9
Click a .csv and a .docx
OnlyOffice opens both (native defFormats, not a custom action)
10
Click a .md, then a random unknown-type file
Nextcloud Text for the .md; VS Code for the unknown one (fallback). Every file has an “Open with” submenu, with Download last.
11
In code-server, open a file under /windows and try to save
VS Code refuses — the path is in files.readonlyInclude
12
UI language on a fresh login
Hungarian (hu / hu_HU), landing on Files; the per-user language switcher is still available (force_language unset)
13
Point an MCP client at https://drive.taild4189d.ts.net:8444/mcp
Tools list; no 421. A 421 means allowed_hosts is unset — see .
If a browser check fails but every host check above is green
It is almost certainly a client → node reachability / ACL / cert-trust issue on that device, not a stack misconfiguration. Check that the device is in the node’s ACL and can resolve MagicDNS before touching any config on the box.
Backups / rollback
Every file the build modified was backed up in place. Reverting = copy the .bak back and restart the relevant thing.
Backup
Reverting it undoes
/home/levander/nextcloud/docker-compose.yml.bak
The hardening pass: removes the x-shared-env anchor, the cron sidecar, the hsts.conf mount, OVERWRITECLIURL and the raised PHP limits → back to the bare Task-1 nextcloud/db/redis stack.
/home/levander/nextcloud/.env.bak
The hardening .env additions: OVERWRITECLIURL, PHP_MEMORY_LIMIT=1024M, PHP_UPLOAD_LIMIT=16G. Also predates ONLYOFFICE_JWT_SECRET — restoring it breaks OnlyOffice.
The three ownership-normalization ExecStartPre steps → back to scan-only. Taildrive-written files stay root:root and become read-only from the web UI. Requires systemctl daemon-reload.
Earlier same-session services.yaml snapshots from the CAD/3D-viewer and tailnet dashboard edits. Older than the two above — restoring one of these also drops the Nextcloud and Code Server tiles.
/var/www/html/custom_apps/cadviewer/js/cadviewer-main.js.stl.bak (inside the container, in the persistent app volume)
The NC-34 file-action fix → back to the broken registration where .stl clicks download. Restore with cp … cadviewer-main.js && chown www-data:www-data cadviewer-main.js, and occ config:app:delete theming cachebuster.
Things with no .bak — they were purely additive; revert by removing:
# scan timer (Task 5) — the units themselves were newsudo -n systemctl disable --now nextcloud-scan.timersudo -n rm /etc/systemd/system/nextcloud-scan.{service,timer} && sudo -n systemctl daemon-reload# Taildrive sharessudo -n tailscale drive unshare drivesudo -n tailscale drive unshare cad# the cadviewer app (lives entirely in the persistent app volume; no compose edit was made)docker compose exec -T --user www-data nextcloud php occ app:disable cadviewer# code-server (all new: compose dir, .env, workspace dir, sidecar unit)cd /home/levander/code-server && docker compose downsudo -n systemctl disable --now tailscaled-code.service# the drive sidecar + identity proxy (all new units)sudo -n systemctl disable --now tsauth-proxy.service tailscaled-drive.service# the /windows external storage (id 2) — removing it does NOT unmount /mnt/windocker compose exec -T --user www-data nextcloud php occ files_external:delete 2# …then drop the ":ro" bind from docker-compose.yml, and the /etc/fstab UUID line if the# host mount is no longer wanted. Leave the fstab line UUID-based if you keep it.# drive-mcp (new unit + new serve mount on the existing drive node)sudo -n systemctl disable --now drive-mcp.servicesudo -n tailscale --socket=/run/tailscale-drive/tailscaled.sock serve --https=8444 off# bring filebrowser back (container stopped, volumes preserved, serve mount removed)docker start filebrowsersudo -n tailscale --socket=/run/tailscale-<svc>/tailscaled.sock serve --bg --https=8445 http://127.0.0.1:8445