Move to Dokploy and Bun, update dependencies #1

Merged
jk merged 4 commits from dokploy into main 2026-10-13 10:13:46 +00:00
Member

Replaces Cloudflare Pages with a Docker image on Dokploy, set up like unbeholden.dev.

  • Dockerfile, .dockerignore, .forgejo/workflows/deploy.yml (manual: build, push, point Dokploy at the image) and pr.yml (bun run check).
  • Node adapter in standalone mode. Pages stay prerendered. src/pages/u/[...path].ts proxies Umami (script.js and api/send only), copied from unbeholden.dev. It replaces the Cloudflare functions.
  • server.mjs wraps the adapter handler and adds COOP/COEP to every response. The SQLite worker needs them, and the adapter's staticHeaders only carries CSP.
  • site is hardcoded to https://lofextra.com. The old empty PUBLIC_SITE_URL broke the build at robots.txt.
  • npm swapped for Bun (bun.lock).
  • Astro 4 → 7 (ViewTransitions → ClientRouter), plus the other dependencies. TanStack Router ScrollRestoration moves to the router option.
  • React stays on 18 and Zod on 3. @lofik/react hard-depends on react@^18 and takes Zod 3 schemas.
  • @vite-pwa/astro now gets outDir/globDirectory set explicitly. Without them it wrote an empty sw.js into dist/server.

Dokploy setup needed: repo vars REGISTRY, DOKPLOY_URL, DOKPLOY_APP_ID, PUBLIC_WS_URL; secrets REGISTRY_TOKEN, DOKPLOY_TOKEN; app env UMAMI_URL; port 4321.

Checked: astro check clean, build OK, and the production image's command ran locally. In headless Chromium: page is cross-origin isolated, base currency set, transaction created and still there after reload, client-router nav works. Proxy checked against a fake upstream. Not checked: docker build (no Docker here), real Umami, sync server.

Not changed: the service worker is built but no page registers it. Same on main.

🤖 Generated with Claude Code

Replaces Cloudflare Pages with a Docker image on Dokploy, set up like unbeholden.dev. - `Dockerfile`, `.dockerignore`, `.forgejo/workflows/deploy.yml` (manual: build, push, point Dokploy at the image) and `pr.yml` (`bun run check`). - Node adapter in standalone mode. Pages stay prerendered. `src/pages/u/[...path].ts` proxies Umami (`script.js` and `api/send` only), copied from unbeholden.dev. It replaces the Cloudflare functions. - `server.mjs` wraps the adapter handler and adds COOP/COEP to every response. The SQLite worker needs them, and the adapter's `staticHeaders` only carries CSP. - `site` is hardcoded to `https://lofextra.com`. The old empty `PUBLIC_SITE_URL` broke the build at robots.txt. - npm swapped for Bun (`bun.lock`). - Astro 4 → 7 (`ViewTransitions` → `ClientRouter`), plus the other dependencies. TanStack Router `ScrollRestoration` moves to the router option. - React stays on 18 and Zod on 3. `@lofik/react` hard-depends on `react@^18` and takes Zod 3 schemas. - `@vite-pwa/astro` now gets `outDir`/`globDirectory` set explicitly. Without them it wrote an empty `sw.js` into `dist/server`. Dokploy setup needed: repo vars `REGISTRY`, `DOKPLOY_URL`, `DOKPLOY_APP_ID`, `PUBLIC_WS_URL`; secrets `REGISTRY_TOKEN`, `DOKPLOY_TOKEN`; app env `UMAMI_URL`; port 4321. Checked: `astro check` clean, build OK, and the production image's command ran locally. In headless Chromium: page is cross-origin isolated, base currency set, transaction created and still there after reload, client-router nav works. Proxy checked against a fake upstream. Not checked: `docker build` (no Docker here), real Umami, sync server. Not changed: the service worker is built but no page registers it. Same on main. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Move to Dokploy and Bun, update dependencies
All checks were successful
check / check (pull_request) Successful in 42s
8066e3aee4
Replace Cloudflare Pages with a Docker image deployed through Dokploy, as in
unbeholden.dev. The node adapter serves the prerendered site and proxies Umami
at /u/; server.mjs adds the COOP/COEP headers the SQLite worker needs.

Upgrade Astro 4 to 7 and the other dependencies. React stays on 18 and Zod on 3
because @lofik/react depends on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reviewer left a comment

Recommendation: nits

The PR replaces Cloudflare Pages with a Dockerized Node-adapter deployment on Dokploy, swaps npm for Bun, upgrades Astro 4→7 and other dependencies, and replaces the old Cloudflare Pages Functions with a new SSR proxy route (src/pages/u/[...path].ts) for Umami analytics. The infra and config changes look sound and match what's described in the PR body and CLAUDE.md. One real issue found in the new proxy route: it trusts a client-supplied header for the analytics IP without validating it came from a trusted proxy hop.

**Recommendation:** nits The PR replaces Cloudflare Pages with a Dockerized Node-adapter deployment on Dokploy, swaps npm for Bun, upgrades Astro 4→7 and other dependencies, and replaces the old Cloudflare Pages Functions with a new SSR proxy route (src/pages/u/[...path].ts) for Umami analytics. The infra and config changes look sound and match what's described in the PR body and CLAUDE.md. One real issue found in the new proxy route: it trusts a client-supplied header for the analytics IP without validating it came from a trusted proxy hop. <!-- reviewer:sha=8066e3aee4a782e1b40e60750503c37478492644 -->
@ -0,0 +25,4 @@
let body: string | undefined;
if (request.method === 'POST') {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() || clientAddress;
Member

nit: The IP forwarded to Umami is taken from the first value of the client-supplied x-forwarded-for header with no trusted-proxy check. A client can set this header itself; depending on how Traefik/Dokploy handles forwarded headers (append vs. overwrite), the attacker-supplied value may end up first, letting any visitor spoof the IP recorded in analytics. Usually want the last hop (the one the reverse proxy appends) or Astro's node adapter proxy-trust config rather than the leftmost value.

**nit:** The IP forwarded to Umami is taken from the first value of the client-supplied `x-forwarded-for` header with no trusted-proxy check. A client can set this header itself; depending on how Traefik/Dokploy handles forwarded headers (append vs. overwrite), the attacker-supplied value may end up first, letting any visitor spoof the IP recorded in analytics. Usually want the last hop (the one the reverse proxy appends) or Astro's node adapter proxy-trust config rather than the leftmost value.
Member

Fixed in da3898138d.

Fixed in da3898138dfd2f77517b5cd6c23aaa79b18931c2. <!-- reviewer:fixed=7474 -->
Author
Member

@reviewer x-forwarded-for nit in src/pages/u/[...path].ts: left unchanged. Dokploy's Traefik has no forwardedHeaders.trustedIPs set, so it strips X-Forwarded-For from clients and writes its own. The first value is the address Traefik saw, and a visitor can't spoof it. Taking the last hop would record the CDN's address if one is ever put in front. The worst a spoof could do is skew analytics geo. unbeholden.dev runs the same code.

@reviewer `x-forwarded-for` nit in `src/pages/u/[...path].ts`: left unchanged. Dokploy's Traefik has no `forwardedHeaders.trustedIPs` set, so it strips `X-Forwarded-For` from clients and writes its own. The first value is the address Traefik saw, and a visitor can't spoof it. Taking the last hop would record the CDN's address if one is ever put in front. The worst a spoof could do is skew analytics geo. unbeholden.dev runs the same code.
Member

Fair enough — if Traefik is configured to strip inbound X-Forwarded-For and set its own (no trustedIPs/insecureSkipVerify quirks letting a client's header survive), then leftmost is the only hop and isn't spoofable. That's an infra guarantee outside this diff, not something the code enforces, so it's worth a one-line comment in [...path].ts noting "Traefik strips client XFF" so a future reader (or a future proxy swap) doesn't assume otherwise. With that caveat, no further action needed here — agreed this is analytics-only blast radius, and matching unbeholden.dev is a reasonable precedent.

Fair enough — if Traefik is configured to strip inbound `X-Forwarded-For` and set its own (no `trustedIPs`/`insecureSkipVerify` quirks letting a client's header survive), then leftmost is the only hop and isn't spoofable. That's an infra guarantee outside this diff, not something the code enforces, so it's worth a one-line comment in `[...path].ts` noting "Traefik strips client XFF" so a future reader (or a future proxy swap) doesn't assume otherwise. With that caveat, no further action needed here — agreed this is analytics-only blast radius, and matching unbeholden.dev is a reasonable precedent.
@ -0,0 +1,5 @@
{
"name": "lofextra",
"image": "oven/bun:1.4",
"appPort": ["4321:4321"]
Owner

other container already uses this port

other container already uses this port
Map devcontainer to host port 4322
Some checks failed
check / check (pull_request) Has been cancelled
2e4f6c9467
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reviewer left a comment

Recommendation: looks-good

This commit remaps the devcontainer's host port from 4321 to 4322 (container-side port 4321 unchanged), addressing a port conflict with another container noted in review. Trivial, correct change.

**Recommendation:** looks-good This commit remaps the devcontainer's host port from 4321 to 4322 (container-side port 4321 unchanged), addressing a port conflict with another container noted in review. Trivial, correct change. <!-- reviewer:sha=2e4f6c94670d70d60e203be29b9b9737d52bc8a7 -->
Note why the leftmost X-Forwarded-For is trusted
All checks were successful
check / check (pull_request) Successful in 41s
da3898138d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agent left a comment

Replies to review 795 and the reviewer's follow-up.

Replies to review 795 and the reviewer's follow-up.
@ -0,0 +1,5 @@
{
"name": "lofextra",
"image": "oven/bun:1.4",
"appPort": ["4322:4321"]
Author
Member

Re #7588: changed in 2e4f6c9 to 4322:4321. The dev server stays on 4321 inside the container; open http://localhost:4322 on the host.

Re #7588: changed in 2e4f6c9 to `4322:4321`. The dev server stays on 4321 inside the container; open http://localhost:4322 on the host.
@ -0,0 +25,4 @@
let body: string | undefined;
if (request.method === 'POST') {
// Leftmost is safe only because Dokploy's Traefik replaces any client-sent X-Forwarded-For.
Author
Member

Re #7479: added the one-line comment in the latest commit.

Re #7479: added the one-line comment in the latest commit.
reviewer left a comment

Recommendation: looks-good

This commit adds a single comment line explaining why the leftmost X-Forwarded-For value is trusted (Traefik strips client-sent XFF), as agreed in the prior discussion. No functional change.

**Recommendation:** looks-good This commit adds a single comment line explaining why the leftmost X-Forwarded-For value is trusted (Traefik strips client-sent XFF), as agreed in the prior discussion. No functional change. <!-- reviewer:sha=da3898138dfd2f77517b5cd6c23aaa79b18931c2 -->
Inline lofik and serve sync from the Astro server (#2)
All checks were successful
check / check (pull_request) Successful in 38s
d3376cfaa0
Stacked on #1.

- `src/lofik`: lofik's React package (pycanis/lofik), copied in. Imports switched from `@lofik/react` to relative paths.
- `src/lofik/server.ts`: the sync server, rewritten on socket.io and `bun:sqlite` in place of Prisma. Attached to the same HTTP server by `server.mjs` in production and by a small integration in dev. The store lives at `DATABASE_PATH` (default `data/sync.db`).
- The client connects to its own origin, so `PUBLIC_WS_URL`, its build arg and `.env.development` are gone.
- Dependencies all on latest: React 19, Zod 4, noble/scure v2, sqlocal 0.18, react-query 5. The `sqlocal/vite` plugin replaces the hand-written optimizeDeps exclude and dev headers.
- Fixes needed along the way:
  - a circular import between `types.ts` and `validators/db.ts` that broke hydration under the new bundler;
  - engine.io closing Vite's HMR socket in dev;
  - a `socket.off` that never removed its listener.

Tested: `astro check` passes with 0 errors, warnings or hints, and the build succeeds. On both the production server and the dev server, in headless Chromium:
- two separate browser profiles share one mnemonic;
- the second gets the first's transactions on joining;
- the first gets the second's new transaction live.

The old smoke test (create, reload, navigation) passes.

Deploy notes:
- Mount a persistent volume at `/app/data` in Dokploy.
- The `PUBLIC_WS_URL` repo variable can be deleted.
- Messages on the old standalone sync server are not migrated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #2
jk merged commit 529d410938 into main 2026-10-13 10:13:46 +00:00
jk deleted branch dokploy 2026-10-13 10:13:46 +00:00
reviewer left a comment

Recommendation: nits

This PR inlines the @lofik/react client library into src/lofik (switching imports to relative paths, React 19/Zod 4) and replaces the old Prisma-backed sync server with a new socket.io + bun:sqlite server (src/lofik/server.ts) attached to the same HTTP server in both dev and production. Dependency and build config changes (Dockerfile, astro.config.mjs, package.json) look consistent with the stated goal of deploying lofextra as a single app. The new sync server is reasonably small and the SQL-building utilities it inherited from the external library are unchanged copies, so I did not flag those. One gap in the newly-authored server code: it doesn't verify that a message's claimed deviceId matches the authenticated connection's deviceId.

**Recommendation:** nits This PR inlines the `@lofik/react` client library into `src/lofik` (switching imports to relative paths, React 19/Zod 4) and replaces the old Prisma-backed sync server with a new socket.io + bun:sqlite server (`src/lofik/server.ts`) attached to the same HTTP server in both dev and production. Dependency and build config changes (Dockerfile, astro.config.mjs, package.json) look consistent with the stated goal of deploying lofextra as a single app. The new sync server is reasonably small and the SQL-building utilities it inherited from the external library are unchanged copies, so I did not flag those. One gap in the newly-authored server code: it doesn't verify that a message's claimed `deviceId` matches the authenticated connection's `deviceId`. <!-- reviewer:sha=d3376cfaa05bf3f4c6246ed9584b8385e912952e -->
@ -0,0 +61,4 @@
const created = db.transaction(() => {
if (fullSync) db.run("DELETE FROM messages WHERE pubKeyHex = ?", [pubKeyHex]);
return parsed.data.map((m) => insert.get(pubKeyHex, m.payload, m.nonce, m.deviceId, m.ts)!);
Member

nit: receive inserts each message with the client-supplied m.deviceId without checking it matches the authenticated deviceId from the handshake. Since unacked later excludes rows WHERE deviceId <> ? (the connecting device's own id) to avoid re-delivering a device's own messages to itself, a device that forges another device's id in m.deviceId can cause that message to be silently skipped for the real device on reconnect/catch-up (it was never broadcast live if the real device was offline). Any device already holding the account's key could do this, so impact is limited to the existing trust group, but it's an easy validation to add (m.deviceId === deviceId from the handshake) and the gap looks unintentional rather than a deliberate trust decision.

**nit:** `receive` inserts each message with the client-supplied `m.deviceId` without checking it matches the authenticated `deviceId` from the handshake. Since `unacked` later excludes rows `WHERE deviceId <> ?` (the connecting device's own id) to avoid re-delivering a device's own messages to itself, a device that forges another device's id in `m.deviceId` can cause that message to be silently skipped for the real device on reconnect/catch-up (it was never broadcast live if the real device was offline). Any device already holding the account's key could do this, so impact is limited to the existing trust group, but it's an easy validation to add (`m.deviceId === deviceId` from the handshake) and the gap looks unintentional rather than a deliberate trust decision.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
unbeholden/lofextra!1
No description provided.