Self-hosted sync backend for the Obsidian LiveSync plugin, following
https://blog.admin-intelligence.de/selbsthosting-von-obsidian-mit-couchdb-kostenlose-synchronisation-einrichten/.
local.ini.example carries the required chttpd/cors config; local.ini itself
is gitignored since CouchDB rewrites it in place (hashed admin password,
node uuid) on first start, so a tracked copy would never stay clean. The
config also has to be mounted read-write, not :ro: CouchDB's entrypoint
chowns everything under /opt/couchdb and aborts under set -e if that fails.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
statuscode "400,401,403-499" counted almost every client error as a
brute-force strike, so normal 404s/400s from routine app behavior could
rack up 5 hits in 10 minutes and trip a 3h ban (returned as 429) for real
users. Narrow to 401/403 (actual auth failures) and raise maxretry to 15
so it still catches real brute-forcing without false-positiving on noise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.ui.negative.message .header only tinted the header; the message body and
the warning/success/info variants had no color rule at all across every
Catppuccin theme.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Drop the standalone Code Server/Passbolt/Umami docker widgets in
favor of a compact multi-link bookmark card for every self-hosted
service, plus a second one linking out to the Coolify-hosted web
apps (pivoine.art, bar.pivoine.art, etc.). Requires pulsenode
v0.4.4+ for the bookmark widget's new `links` field.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
Matches pulsenode v0.4.3, which folded the dedicated traefik widget
type into the same combined docker+API pattern as the other six
services. No apiUrl needed anymore - base URL is derived from
containerName like every other service widget.
Separates system/traefik (host+proxy infra) from the per-service
widgets into their own group, and gives the traefik widget an icon
and public dashboard link now that pulsenode's traefik widget
supports both.
Uses the new combined docker+API widget type shipped in pulsenode
v0.4.0. Umami and Passbolt stay as plain docker widgets - Umami has no
static API key for self-hosted instances (needs a login flow, skipped
for now), Passbolt has no widget-relevant API to poll.
Docker's provider drops a container's own docker-label routers when
Docker reports that container unhealthy. --ping=true had been dropped
somewhere along the way, so /ping 404'd, the healthcheck failed,
traefik was marked unhealthy, and its own traefik-dashboard-web(-secure)
routers disappeared as a result. Also restores acme.tlschallenge=true
(needed for cert renewal) and drops the debug log level used for
earlier troubleshooting.
Leftover from an earlier failed attempt (referenced a since-removed
"internal" entrypoint, logging harmless "EntryPoint doesn't exist"
errors). The api.insecure + named "traefik" entrypoint approach now
in place supersedes it.
The public traefik.pivoine.art path round-trips through the host's
published port, and Docker's hairpin NAT there rewrites the source IP
before it reaches vpn-only's allowlist, so pulsenode's own request was
getting a 403 despite falcon_network being allowlisted. Port 8080 is
not published to the host, so it's reachable only from other
falcon_network containers - no TLS/vpn-only needed, and it's not
exposed externally.
Switches traefik.pivoine.art routing from a standalone file-provider
router (dynamic/dashboard.yaml) to docker-compose labels on traefik's
own service - the same pattern every other stack already uses for its
own routing, rather than a one-off exception. Traefik discovers itself
just like any other container on falcon_network. Both routers point
at the built-in api@internal service via .service= instead of a
loadbalancer target, since there's no backend container port to route
to. Also dropped the (PathPrefix(`/api`) || PathPrefix(`/dashboard`))
condition - the whole traefik.pivoine.art host is dedicated to this,
so a bare Host() match is simpler and no less correct.
Also fixes why VPN access still failed: traefik.pivoine.art was never
added to Headscale's MagicDNS extra_records, so even a tailnet-
connected browser resolved it via public DNS and hit vpn-only from a
non-tailnet source IP - same root cause as the earlier
pulsenode.pivoine.art fix, just missed for this hostname.
Previous attempt tried a separate internal-only :8080 entrypoint for
pulsenode to reach the API directly - that turned out to 404 no
matter what (--api.dashboard=true alone didn't register a working
router there, and adding --api=true plus an explicit entrypoint still
didn't produce a matching router; verified against a local traefik:v3
container before giving up on that path rather than keep guessing
against the live one).
Cleaner approach: expose it through the same web/web-secure
entrypoints everything else already uses, via a new file-provider
router (traefik/dynamic/dashboard.yaml) matching Traefik's own
documented self-referencing pattern - Host(`traefik.pivoine.art`) &&
(PathPrefix(`/api`) || PathPrefix(`/dashboard`)) routed to the built-in
api@internal service, gated by vpn-only same as pulsenode's own router.
vpn-only's sourceRange now also includes falcon_network's own subnet
(172.18.0.0/16) alongside the tailnet ranges - a container on our own
internal docker network is as trusted as a tailnet peer, and without
this pulsenode's own request to the API (a container-to-container
call, not a VPN-sourced one) would get the same 403 a random internet
visitor would. pulsenode's traefik widget now points at
https://traefik.pivoine.art/api instead of the internal :8080 attempt.
--api.dashboard=true alone wasn't enough - the API returned 404 on
every path after enabling it. Traefik apparently doesn't reliably
auto-create the default "traefik" entrypoint the way older docs
describe; defining it explicitly is the documented, reliable way to
get the API/dashboard router actually listening. Still not in ports:,
so still only reachable from other containers on falcon_network.
- traefik/compose.yml: flip --api.dashboard=true so Traefik's internal
API is reachable. No entrypoint named "traefik" is defined, so
Traefik auto-creates its default one on :8080 - not published to the
host (only 80/443 are), so it's reachable only from other containers
on falcon_network, i.e. exactly (and only) pulsenode.
- pulsenode/config.yml: adds a system widget and swaps the plain
Traefik docker card for the real traefik widget (router/entrypoint/
middleware view via that now-enabled API), both at the top of
Services.
Verified locally before deploying: a plain container (no pid: host,
no /proc,/sys mounts) already reports host-accurate CPU count, memory
total, and root disk usage via systeminformation, since Docker doesn't
namespace /proc/stat, /proc/meminfo, or overlayfs df by default. No
privilege escalation needed for the system widget after all - tested
side by side against the real host's nproc/free/df output. Also
confirmed the merged config.yml validates against the app's own zod
schema, not just as YAML.
Trims the deployed config down to what's actually useful day-to-day:
a single Services group with the 10 stacks as plain docker widgets
(traefik, coolify, gitea, code, immich, n8n, passbolt, umami,
headscale, mailpit). Drops the Data Services group (8 database
widgets), the Search widget, and auto-discovery (and the synthetic
Discovered group it produced) - all still fully supported by the app
itself and demonstrated in the pulsenode repo's own dogfood config,
just more than this deployment needs.
Mirrors the pulsenode app repo's updated config.yml (v0.3.0): removes
the leftover --pn-accent: #38bdf8 override that was masking the new
neon-green default, and adds icon: entries for every service that has
a brand match in lib/brand-icons.ts.
vpn-only (the ipAllowList middleware pulsenode now uses) checks the
connection's source IP, but being on the tailnet doesn't reroute
traffic to a normal public DNS hostname through the tunnel - Tailscale
only reroutes destinations it actually knows about. pulsenode.pivoine.art
had no such record, so it always resolved publicly for everyone,
tailnet or not, and Traefik never saw a tailnet-range source IP to
allow. Same fix already in place for triggershell.falcon.pivoine.art:
an extra_records override served only over MagicDNS, pointing to the
VPS's own tailnet IP so tailnet clients' connections actually traverse
the tunnel and satisfy the allowlist.
falcon-vpn-only was defined inline in falcon.yaml even though it's a
generic ipAllowList restriction with nothing falcon-specific about it
- security.yaml is where the other reusable middlewares (security-
headers, no-index, rate-limit) already live. Renamed to vpn-only to
match their bare naming style now that it's not tied to one file, and
updated falcon.yaml's two router references to vpn-only@file (same
cross-file pattern already used there for security-headers@file).
Also applies it to the pulsenode stack: it's a dashboard surfacing
container names/health for every service here, and there's no auth in
front of it yet, so gating it to the tailnet is a real mitigation for
that gap rather than leaving it open on the public hostname.
Infrastructure dashboard monitoring all the stacks in this repo, built
via stacks new pulsenode and then hand-adjusted for what a generic
scaffold can't know: pulls the published image
(dev.pivoine.art/valknar/pulsenode:latest) instead of building, mounts
/var/run/docker.sock:ro plus its own config/ directory read-only, runs
read-only/non-root/cap-dropped (group_add on DOCKER_GID to get socket
access without running as root), and uses its own healthcheck.mjs
rather than curl (not present in its minimal image).
config/config.yml is real, not a placeholder: docker/database widgets
for every container across all ten stacks here, discovery enabled
against falcon_network as a safety net for anything added later.
Add a Traefik IP allowlist so *.falcon.pivoine.art rejects any source
outside the Tailscale/Headscale CIDR ranges, even though DNS still
resolves publicly. Pair it with a headscale MagicDNS override so
tailnet clients resolve the hostname straight to the VPS's tailscale
IP and route correctly.
Requires Docker's userland-proxy disabled on the VPS host
(/etc/docker/daemon.json) so Traefik sees real client source IPs
instead of the docker bridge gateway — done manually, not tracked
in this repo.
Dark Frappé base (#303446) background with the pivoine.art peony
centered and padded. Replaces logo.svg/png, favicon.svg/png, and
apple-touch-icon.png.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Port 0 disabled the web UI but broke the built-in /readyz healthcheck.
Binding to 8025 keeps the UI accessible within the Docker network only
(no Traefik labels) and lets the container report healthy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
IONOS rejects relay from VPS IPs when sender doesn't match authenticated
account. Switch gitea and passbolt FROM to the actual IONOS account address.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
stacks.sh now lives in git@dev.pivoine.art:valknar/scripts.git
and resolves STACKS_DIR from env or defaults to ~/stacks.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same fix as the update service: gate "not installed" on unit file
existence rather than systemctl exit code, which is non-zero for
failed services too.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
systemctl status exits non-zero for failed services (exit 3), not
only missing ones (exit 4), causing the || warn branch to always fire
after a failed run. Gate the "not installed" warning on whether the
unit file exists instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docker image inspect returns exit code 1 for images not yet pulled.
With set -euo pipefail, this silently killed the script mid-loop.
Add || true so an uncached image yields empty before/after (correct
first-run behaviour: pull fires, before != after, stack gets started).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Docker binds to both [::]:22 and 0.0.0.0:22 without an explicit IP,
conflicting with sshd on [::]:22.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ondemand kills all workers after 10s idle, causing cold Laravel
bootstrap (10-54s) on every request after any quiet period.
dynamic keeps min_spare_servers=1 alive at all times; the other
PM_* values (start_servers=2, min=1, max_spare=3) were already
set but ignored under ondemand.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>