gogogofullstacktemplate

package module
v0.21.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 15, 2026 License: MIT Imports: 0 Imported by: 0

README

gogogo-fullstack-template

gogogo-fullstack-template

Built to be useful. Every decision favors practical outcomes over abstract ideals. The stack intentionally optimizes for simplicity, consistency, and shipping software with minimal friction.

I still don't understand why GitHub keeps crediting Claude as a contributor to my repos. My daily drivers are pi.dev with Minimax M3 and DeepSeek V4 Flash. Claude, if you're freelancing on my repositories while I'm asleep, at least start fixing the bugs too. If anyone knows how GitHub actually computes contributors, I'd genuinely love to know.

Contents


Every web project we start begins with the same conversation: pick a database, auth, router, reactive UI framework, task queue… and the project stalls at the decisions, installations, and configurations — not the code.

Who this template is for

  • You who get tired of configuring the same stack over and over
  • You who want everything in one binary, with no external dependencies, no Docker required. One self-contained file. Environment-independent.
  • You who need offline-first resilience. A Service Worker + Background Sync queue (web) and a NATS Leaf Node (desktop) let clients keep working without a connection and replay mutations on reconnect, with idempotency so replays never duplicate. See Hybrid offline sync.
  • You who prefer a single source of truth on the backend, one language for the whole stack, and reactive UI without heavy frontend frameworks — server-rendered HTML via SSE, lightweight and fast, no bloated SPAs, no JS build step.
  • You who want a language that is predictable for both humans and LLMs. Go's syntax is minimal and consistent. Same formatting everywhere (gofumpt). No surprises. Static typing catches whole classes of bugs at compile time. Native concurrency (goroutines + channels) that is easy to reason about — no async/await chains, no callback pyramids. This makes the codebase equally readable by you, your team, and AI coding agents.
  • You who care about supply chain security. Go has no mass npm-style dependency trees. Every module is verified by content hash (go.sum). Built-in vulnerability auditing (govulncheck) scans your dependency graph for known CVEs. No transitive dependency hell.
  • You who want an LLM client wired in without pulling in a whole orchestration framework — internal/llm wraps GoAI (any OpenAI-compatible provider) behind an injectable interface, callable from handlers. It calls a remote provider API; it is not a local-model runtime.

What's in the package

Everything you need to build a modern web app, in a single binary:

Layer Choice Why
Language Go 1.26 Fast compilation, easy deploy, lean runtime
Database + Auth + API PocketBase (embedded, on ncruces/go-sqlite3) Zero-config auth, REST, admin UI at /_/, file storage — all in SQLite
Templating Templ Type-safe Go components, generated at build time
Reactive UI Datastar (SSE) Server-rendered over SSE, single ~12 KiB client. CSS built once via Tailwind v4 CLI; no JS framework build step.
CSS DaisyUI v5 + TailwindCSS Ready components, customizable, ~34kB minified
Task queue goqite + SSE Hub Background jobs streamed to the browser, no Redis
Retries avast/retry-go v4 Exponential backoff with jitter, no boilerplate
Durable Workflows DagNats Multi-step durable workflows as declarative JSON over NATS JetStream
LLM SDK GoAI Any provider: OpenAI, Anthropic, Groq, Ollama…
Real-time NATS JetStream Multi-user real-time, cross-instance broadcast
Secrets age + ~/.secrets/ Local encryption, no vault, no cloud
IDs google/uuid Stable request/job IDs
Live reload Air make dev regenerates templ and restarts the binary
CRDT (collaborative docs) loro-go Conflict-free merging of whiteboard/notes state; converges offline edits with no LWW data loss
Hand-drawn canvas Rough.js (embedded) Minimalist sketchy whiteboard rendering, embedded in the binary for self-contained removal with the whiteboard feature
Linting golangci-lint + datastar-lint 27 linters: govet, staticcheck, gosec, revive, gocritic, errcheck, ineffassign, unused, errorlint, nilerr, bodyclose, contextcheck, containedctx, sloglint, thelper, testifylint, gocyclo, gocognit, funlen, noctx, goconst, dupl, lll, mnd, tagliatelle, modernize, nolintlint (see .golangci.yml); datastar-lint catches Datastar attribute/signal/expression mistakes (run via make datastar-lint)
CI/CD GitHub Actions ci.yml (lint + test + build, unified build) + deploy.yml (multi-arch Docker to ghcr.io, runs on master)

Why ncruces/go-sqlite3? It's the pure-Go (no cgo) SQLite engine this template standardizes on and the always-on driver — db/pocketbase.go registers it as the sqlite3 database/sql driver that every query uses. PocketBase also bundles modernc.org/sqlite, but that registers itself as sqlite and stays unused, so no build tag is required and a plain go build just works. Being cgo-free means clean cross-compilation for the multi-arch Docker image (linux/amd64 + arm64) and the Wails desktop/mobile builds.

Stack in layers, not silos

Most templates force you to pick one async strategy — usually a queue, sometimes a workflow runtime, rarely both. Real apps need a queue for background jobs, a workflow runtime for durable multi-step processes, a collaboration layer for conflict-free state merging, and a real-time layer for cross-client state — each solving a different problem. We ship all five in one unified build.

We solve this with six complementary layers:

goqite       → background jobs + SSE hub (always on)
dagnats      → durable multi-step workflows as JSON (runtime opt-out: DAGNATS_ENABLED=false)
Loro CRDT    → collaborative docs with offline merges (opt-out by removing internal/collab/)
PB realtime  → record-change push via PB's native /api/realtime (always on, per-user scoped)
SSE Hub      → ephemeral signals via Datastar protocol (always on, part of queue)
JetStream    → multi-instance broadcast + cross-instance state (runtime opt-out: NATS_ENABLED=false)

Two realtime mechanisms for different jobs. PocketBase's native /api/realtime pushes record mutations (create/toggle/delete) to subscribers, scoped per-user by the collection's access rules. The SSE Hub (internal/queue/ssehub.go) is reserved for ephemeral signals (client count, LLM suggest feedback, workflow progress, self-patches) and delivers them via Datastar's SSE protocol (internal/datastar.RenderAndPatch / MergeSignals). The todo feature uses both: PB realtime for CRUD propagation, SSE Hub for toasts and live hints. The whiteboard uses the SSE Hub directly for shape + presence broadcast.

Cross-instance sync adds two more paths. When JetStream is enabled (default: on), the NATS APP_CRUD stream converges record operations across server instances: CrudPublisher publishes each mutation, CrudConsumer on the receiving instance writes to its local PocketBase, which then broadcasts via PB realtime to its local clients. The NATS TODOS stream carries ephemeral signals across instances and re-emits them through the local SSE Hub. Both are safe to enable even on a single instance — the streams simply carry no cross-instance traffic.

Offline sync uses yet another path. On the web, the Service Worker (web/resources/static/sw.js) intercepts POST/PUT/DELETE mutations when the browser is offline, queues them in IndexedDB, and replays them via Background Sync when connectivity returns. On the desktop (NATS Leaf Node), the local JetStream persists mutations to disk and replays them when the Leaf Node reconnects to the server — no Service Worker needed.

Replay is dedup'd at the server. The todo create form attaches a fresh idem_key UUID to every submit; db/idempotency_hook.go intercepts OnRecordCreateRequest and returns the existing record on a (idem_key, owner) match, so a Service Worker replay doesn't create a duplicate todo. The whiteboard avoids this entirely because Loro CRDT ops already carry unique IDs and converge idempotently on their own.

The opt-out rules are simple:

  • Infrastructure components (NATS, DagNats) have runtime env vars in config/config.go. Set NATS_ENABLED=false or DAGNATS_ENABLED=false and the engine won't boot; downstream consumers handle nil gracefully.
  • Product features (Todo, Whiteboard) have no runtime flag. To remove them, delete the package directory and remove the wiring call from router/router.go — that's the SCOPE removal pattern. See Architecture taxonomy.

They coexist in the same binary. They don't compete.

One make build compiles everything. No build tags, no stub files, no matrix. DagNats and NATS share a single embedded JetStream on :4222.

Offline-first is baked in, not bolted on. Web clients intercept mutations in a Service Worker and replay them via Background Sync; the desktop build becomes a NATS Leaf Node that keeps its JetStream replica in sync while offline. Replays are deduplicated server-side (the todo create form attaches an idem_key that the idempotency hook collapses). See Hybrid offline sync.

Feature overview

Every capability is always compiled. What you get:

Capability Runtime opt-out What it does
Todo app + PocketBase realtime — DB actions (create/toggle/delete) stream through PocketBase realtime, per-user scoped via owner rule. SSE Hub for ephemeral signals (toasts, clients count, AI suggest)
Queue + retry — goqite background jobs + retry-go (the "Queue + Retry" demo)
AI Suggest GOAI_API_KEY unset GoAI/Groq call from the todo UI; button hidden when no key
Collaborative whiteboard — Loro CRDT + Rough.js canvas, SSE + NATS broadcast, offline-first outbox replay, PocketBase-persisted snapshots
Multi-instance real-time NATS_ENABLED=false NATS JetStream fan-out for todo + whiteboard sync across >1 instance behind a LB
Durable workflows DAGNATS_ENABLED=false DagNats JSON workflows — HTTP API on :8090, durable state on JetStream :4222 (e.g. WelcomeOnboarding)
Desktop-edge sync NATS_LEAFNODE_URL unset Leaf-Node JetStream replication of Loro updates for desktop/edge clients
Hybrid offline sync OFFLINE_SYNC_ENABLED=false Disables NATS CRUD proxy + Service Worker offline queue (default on). When enabled: desktop edges publish CRUD ops via NATS JetStream, the server's CrudConsumer writes to PocketBase. Web clients use Service Worker + Background Sync for offline queuing and replay. Toggle with a single env var — set to false for always-online deployments, and zero code paths are traversed.

Adding a new feature? Create features/<name>/ with your handlers + templates. Wire it in router/router.go → Init() with a single function call. See ARCHITECTURE.md for the full pattern.

Architecture taxonomy: Core / Plugin / Feature

Every file in the codebase carries a SCOPE annotation at the top to tell agents and developers what can be safely removed:

Annotation Meaning Examples You would…
SCOPE:core 🔴 Binary does not work without it. Some have runtime opt-out via env vars. config/, db/, internal/queue/, internal/secrets/, features/auth/ (middleware), router/, web/resources/ Customize, never remove.
SCOPE:plugin 🟡 Binary works but loses a capability. Swap or delete with its wiring call. internal/datastar/, internal/nats/, internal/dagnats/, internal/llm/, internal/collab/ Swap for another implementation, or delete the package + wiring call (e.g. router.Init, cmd/web/main.go).
SCOPE:feature 🟢 A demo/add-on. Delete the package + remove the wiring call. features/todo/, features/whiteboard/, router/onboarding_dagnats.go, router/realtime_jet.go Keep as reference while building your own, then remove.

Rule of thumb for agents: If you see a SCOPE annotation on a file, respect it. Never delete a SCOPE:core file without asking. Never keep a SCOPE:feature file in production if the domain doesn't need it.

How to remove a plugin or feature component
  1. Delete the package directory (e.g. features/todo/).
  2. Delete dependent packages listed in the Depends on: comment.
  3. Remove the wiring call from router/router.go → Init().
  4. If it was plugin, also remove the start* call in cmd/web/main.go.

See ARCHITECTURE.md for the full dependency graph.

Code quality for LLM agents

This template ships a strict golangci-lint configuration (27 linters) designed to catch the kinds of mistakes LLMs make most often: unchecked errors, insecure patterns, broken context propagation, resource leaks, and inconsistent error wrapping. The goal is not to block development but to redirect agents toward correct Go idioms automatically.

What the linters enforce:

Category Linters What they catch
Correctness govet, staticcheck, errcheck, ineffassign, unused Shadowed variables, dead code, unchecked returns
Error handling errorlint, nilerr, gosec Wrong %w formatting, returning nil inside an error path, hardcoded credentials
Resource safety bodyclose, noctx HTTP bodies and contexts not closed or propagated
Test quality thelper, testifylint, sloglint, containedctx Missing t.Helper(), assert vs require misuse, context embedded in structs
Complexity gocyclo, gocognit, funlen, nestif (implicit via funlen+gocyclo) Functions too long or too nested to hold in working memory
Style revive, gocritic, tagliatelle, goconst, dupl, lll, modernize Non-idiomatic patterns, magic numbers, duplicated code, long lines
Formatting gofumpt + goimports (formatters, not linters) Compulsory consistent layout and import ordering

For LLM agents reading this: before editing any Go file, run make ci-local to establish a baseline. After making changes, run make lint to verify the linters pass. The golangci-lint configuration lives in .golangci.yml at the project root — read it if you need to understand what each linter expects. If a lint forces you to restructure code, that is usually a sign the original approach had a deeper issue.

For human developers: make ci-local runs the full gate (templ + datastar-lint + css-check + golangci-lint + race tests + build). make lint runs just go vet + golangci-lint. We deliberately keep gofumpt and goimports as formatters (not linters) so golangci-lint run never auto-formats your files — formatting is a separate explicit step.

How to run each lint layer:

Command What it checks
make lint go vet + golangci-lint (27 linters)
make datastar-lint Datastar-specific anti-patterns in .templ files
make fmt gofumpt + goimports formatting only
make ci-local Full local gate, identical to CI: templ → datastar-lint → css-check → golangci-lint → race tests → build

The pre-commit hook (make setup) runs gofumpt, goimports, datastar-lint, a CSS staleness check, go mod tidy, and golangci-lint on every commit — so formatting and lint violations never reach the remote. Run make ci-local for the full gate (adds tests + build) before pushing.

The example: Todo App with realtime

We ship a working Todo App:

  • Full CRUD via PocketBase
  • Reactive UI with Datastar + DaisyUI
  • Database actions stream through PocketBase realtime. Todo create/toggle/delete fire PocketBase record events; each subscribed client re-fetches the fragment and morphs #todo-list. Delivery is per-user scoped by the collection's owner rule (@request.auth.id != '' && owner = @request.auth.id), so a client only receives events for its own records. The SSE Hub is reserved for ephemeral signals (success/retry toasts, live clients count, AI suggest) and the originating client's own synchronous patch.
  • Stacked toast notifications (auto-dismiss, manual close, progress bar)
  • Async jobs: handleCreate enqueues a todo_created job; a worker picks it up and streams a success toast to the right browser tab via the SSE Hub (clientID routing)
  • Retries with exponential backoff and jitter (internal/queue/retry.go, retry-go v4) — SSE-aware: a retry emits a lastRetry signal so the UI can show "retrying…"
  • WelcomeOnboarding DagNats workflow (always compiled) that creates 3 example todos via durable steps — kill the server mid-run, restart, watch it resume at the last incomplete step. The workflow is declarative JSON (internal/dagnats/workflow.go), so renaming Go handlers never orphans an in-flight run.
  • Admin unlock via age + ~/.secrets/. The Todo example wires a master-password path: when ADMIN_UNLOCK_TOKEN is set (in the age-encrypted secrets file), the UI shows a "Clear all" form; the handler compares constant-time and clears all todos on match. Demonstrates the age flow end-to-end.
  • AI suggest via GoAI. When GOAI_API_KEY is set, the input gets a "Suggest" button that enqueues an async suggest job (see queue below) and streams the 3 completions back via SSE. It talks to whatever OpenAI-compatible provider GOAI_BASE_URL/GOAI_MODEL point at — see Configuring the LLM. Retries with exponential backoff use the same internal/queue/retry.go as the SSE toast path. For a keyless demo of the exact same queue + retry path, SIMULATE_LLM is on by default (opt out with SIMULATE_LLM=false): a "Suggest (simulated)" button enqueues a job that hits an in-process fake LLM scripting 500 → 200 + delay, so you can watch the retry feedback toasts (enqueued → attempt failed → slow → result).
  • Tests run with -race

This is the contract you should imitate when adding a new feature:

  1. Pure HTTP + Datastar for the user-facing surface.
  2. goqite job for any work that takes more than ~50ms (LLM, email, exports).
  3. SSE toast for async feedback to the originating client via clientID routing.
  4. age-encrypted secret if the feature needs a credential. Every existing feature (toast on create, AI suggest, admin unlock, DagNats onboarding) follows this exact shape.

Enough to understand the pattern.

Adding your own feature

Every feature in this template follows the same pattern. Use it as a blueprint when building yours:

  1. Create features/<name>/ with your HTTP handlers + Templ components.
  2. Wire it in router/router.go → Init() with a single function call.
  3. Use goqite for async work, SSE Hub for user-facing feedback (toasts, progress), and Datastar for reactive UI.
  4. Add SCOPE:feature or SCOPE:core annotations so agents know what they can remove.
  5. Add a RegisterRoutes(se, deps) function and call it from router.Init.

See the Todo feature for the full reference implementation.

Admin & Dashboard

Two built-in admin surfaces, available as soon as the binary boots:

Surface URL What it gives you
PocketBase admin /_/ Data browser, REST playground, superuser management, backups, logs
DagNats console :8090 Workflow runs, step inspection, JSON API for durable workflows

The admin UI is the upstream PocketBase UI, embedded in the same binary on the same port. No extra service to deploy. Point a Cloudflare Tunnel at /_/ and lock it down with PocketBase's own superuser auth.

PocketBase admin UI (/_/)
  • Visual data browser for every collection (todos, users, etc.) with sort/filter/CSV export
  • REST + JS SDK playground for the API endpoints PocketBase generated from your schema
  • Superuser management (create the first one via the install link printed in the server logs)
  • File storage (S3-compatible uploads, images, attachments)
  • Backups (SQLite snapshot, download + restore)
  • Logs (requests, errors, slow queries)

This is not a custom admin panel — it's the upstream PocketBase UI, embedded in the same binary, on the same port. No extra service to deploy, no extra auth to wire. For production, point a Cloudflare Tunnel / Caddy ingress at the same /_/ path and lock it down (IP allowlist, oauth2-proxy in front, or just PocketBase's own superuser auth).

The app never reuses PocketBase's own pb_auth cookie. PocketBase keeps the superuser (_superusers) and regular users as separate auth namespaces with different endpoints, and a single client holds only one auth state (one cookie). Sharing pb_auth for the app session clobbers the admin session in the same browser (and vice-versa) — a well-known PocketBase gotcha (#5050, #1780).

So login issues two cookies:

  • gogogo_auth — the app's own session cookie, read by LoadAuthFromCookie.
  • pb_auth — the same token under PocketBase's native name, so PB-native surfaces (notably the /api/realtime SSE channel for record-change subscriptions) authenticate as the same user. Without it, realtime record events are silently dropped by PB's per-subscriber access check.

The split is intentional, not tech debt — keep the two cookies separate. Best practice: run the admin UI on a separate origin/port (e.g. :8090/_/) so even pb_auth never collides between admin and app.

DagNats console (:8090)

The DagNats workflow engine exposes its own HTTP API + console at DAGNATS_HTTP_ADDR (default 127.0.0.1:8090). Inspect runs, steps, or trigger workflows via the API. The WelcomeOnboarding workflow runs here — declarative JSON over NATS JetStream, kickstarted automatically on first login.

Try it live

A running deployment of this exact template is live. You can touch every feature from the README without cloning:

What URL What you can do
Todo demo app https://gogogo.calionauta.com/ Log in with the seeded demo account (demo@demo.app / demo).
Live PocketBase admin dashboard https://gogogo.calionauta.com/_/ Open the embedded PocketBase UI to browse the todos + users collections, run the REST/JS SDK playground, and inspect logs. The demo's users collection is locked — visitors can log in as the demo user but cannot create or delete accounts through the API or this dashboard (only the superuser can).
Durable workflow engine (DagNats) https://gogogo.calionauta.com:8090 The DagNats HTTP API where the WelcomeOnboarding workflow runs (declarative JSON over NATS JetStream). Inspect runs/steps or trigger them via the API; the Todo demo drives it automatically on first login.

The demo runs the unified build (everything compiled in). DagNats + NATS share a single embedded JetStream on :4222 — DagNats boots it and the whiteboard SyncWorker attaches to it, so there is only one NATS process in the binary. To stand up your own, see Deploy.

Configuring the LLM (GoAI)

The AI Suggest + Queue/Retry demo is wired through GoAI and reads its configuration from the environment (or your age-encrypted secrets file). Two paths:

1. A real OpenAI-compatible provider (recommended for production). Set GOAI_API_KEY, point GOAI_BASE_URL at the provider's /v1 endpoint, and pick a GOAI_MODEL. Any OpenAI-compatible endpoint works — we do not hardcode a provider, you choose. With a key present, the Todo UI shows the Suggest button.

GOAI_API_KEY=sk-...
GOAI_BASE_URL=https://api.groq.com/openai/v1
GOAI_MODEL=llama-3.3-70b-versatile

2. Keyless simulated LLM (on by default — best for trying the queue + retry path in dev). SIMULATE_LLM is enabled automatically (no API key needed); set SIMULATE_LLM=false to disable it. It spins up an in-process fake GoAI client that scripts a realistic failure (500 → retry → slow → 200) so you can watch the retry feedback toasts end-to-end. The UI shows a Suggest (simulated) button that reuses the exact same goqite + retry-go path as the real provider.

If neither GOAI_API_KEY is set nor SIMULATE_LLM is enabled (i.e. SIMULATE_LLM=false and no key), the AI suggest route is not registered and the UI button is hidden. The Todo example keeps working — AI is opt-in, not required.

Getting started

Use this template (green Use this template button above) or clone it:

git clone https://github.com/calionauta/gogogo-fullstack-template.git my-project
cd my-project
make dev

Open http://localhost:8080 and see the Todo App running.

The default port is 8080 (override with PORT). The default branch is master.

Other commands
make build      # Build binary (unified: everything included)
make test       # Run tests with race detector (-p 1 for DagNats engine stability)
make ci-local   # Full local gate: templ + datastar-lint + css-check + golangci-lint + race tests + build
make css        # Build app.min.css (Tailwind v4 + DaisyUI v5)
make dev        # Live reload with Air
make templ      # Regenerate templ components
make lint       # go vet + golangci-lint (27 linters)
make fmt        # gofumpt + goimports formatting
make setup      # Install pre-commit + pre-push hooks
make docker-image  # Build and push multi-arch image to ghcr.io
Build pipeline

The single compile step outside go build is the CSS build. It runs once in the Docker builder stage and the result is embedded into the Go binary via //go:embed — there is no runtime CSS build step, no JS runtime, and no CDN.

src/css/input.css  →  tailwindcss v4 CLI  →  web/resources/static/app.min.css
                                                    │
                                                    └─ //go:embed in the Go binary

The pre-commit hook regenerates app.min.css automatically whenever .templ or .go files change, and make ci-local includes a css-check step that fails the gate if the working CSS file is out of date.

Local CI (gh-signoff)

Pushing to master triggers GitHub Actions (ci.yml runs the full gate, then deploy.yml ships to production). You can run that exact gate on your own machine before pushing, so you don't wait on remote runners (and don't push a broken commit).

We use gh-signoff — a GitHub CLI extension that stamps a green commit status after your local tests pass.

# one-time: install the extension
gh extension install basecamp/gh-signoff

# before pushing: run the full CI gate locally, then stamp the commit
# green (force so it stamps even before the commit is pushed)
make signoff

make signoff runs make ci-local (templ generate → golangci-lint → datastar-lint → CSS check → go test -race -p 1 → go build) and then stamps the current commit green with gh signoff. make ci-local uses golangci-lint as the authoritative formatter/lint gate (the same linter CI runs) rather than the standalone gofumpt binary, which can be a newer release than the one golangci-lint bundles and would otherwise produce false-positive listings. The dependency runs one way: signoff calls ci-local; ci-local never calls signoff — that keeps the local gate clean to run on its own, and reserves the git-stamp for the explicit pre-push moment.

Advisory status, by design. This repo deploys on push to master (not PR merge), so the signoff status is a signal, not a hard gate. We do not run gh signoff install (which would gate PR merges) — it would be meaningless for a push-to-deploy flow, so we leave it off by design.

Desktop & Mobile (Wails v3 + Loro CRDT + NATS Leaf Node)

The same Go backend (PocketBase + queue + router + handlers) also runs as a native desktop app via Wails v3. The desktop build reuses 100% of the business logic — it boots internal/server.Run, serves PocketBase in a goroutine, and points the webview at it through a reverse proxy.

Build commands (Wails v3 CLI):

# Current platform
wails3 build
# Cross-platform
wails3 build GOOS=windows
wails3 build GOOS=linux
wails3 build GOOS=darwin GOARCH=arm64
# macOS .app bundle
wails3 package GOOS=darwin
# Android APK (needs Android SDK/NDK + JDK 21 — see Mobile below)
wails3 android:package

All builds compile with everything included (unified build). If you prefer a plain binary without the wails CLI, make desktop runs go build ./cmd/desktop.

Edge sync. If NATS_LEAFNODE_URL is set, the desktop boots as a NATS Leaf Node that syncs its JetStream streams with your central server — offline edits replay on reconnect. Without it, it runs a standalone embedded NATS for local realtime. On top of that transport, Loro CRDT collaboration (internal/collab) publishes whiteboard updates on app.sync.<docID> and ephemeral multi-user cursors on app.presence.<docID>; the central server persists resolved Loro snapshots to PocketBase (whiteboards collection) and streams presence to browser clients via SSE (GET /api/collab/presence/{docID}).

Desktop builds are local-only — not in CI. Generate them with the commands above: wails3 build for a binary, wails3 package GOOS=darwin for a macOS .app (wrap in a .dmg with hdiutil if you want a redistributable installer). Keeping the desktop out of CI keeps the pipeline lean; the full e2e gate (incl. TestCollab_LeafNodeE2E and TestPresence_SSEBridgeE2E) still runs in ci.yml under the unified build.

Mobile (Android) is opt-in, not in CI. Wails v3 targets Android from the same main.go (Go → libwails.so, WebView frontend) — no separate mobile project. Generate an APK locally with wails3 android:package (or android:package:fat). This requires the Android SDK (API 35) + NDK (26.3.x) + JDK 21; wails3 doctor reports what's missing. Because that toolchain is heavy, APK builds are left to the developer and are not part of the CI matrix. iOS is analogous but requires Xcode.

Deploy to your own box

The default workflow is to clone + make dev for local work. For a permanent demo, the project ships a production deploy workflow that publishes to a server of your choosing (recommended: a small Linux box + Tailscale + a Cloudflare-tunneled domain). No registry, no cold starts, full control.

Server layout (multi-project standard)

Pick a directory on your server (e.g. /opt) and follow this layout for every project that adopts the pattern — siblings share the same shape:

/opt/
└── gogogo-fullstack-template/                  ← this project
    ├── bin/
    │   ├── gogogo-fullstack-template             ← current binary (chmod 755)
    │   └── gogogo-fullstack-template.previous    ← prior binary, kept for fast rollback
    ├── compose/
    │   └── docker-compose.prod.yml
    ├── env/
    │   └── .env                       ← non-secret env (DATABASE_URL, APP_URL, ...)
    ├── secrets/
    │   └── gogogo-fullstack-template.env         ← mode 600, regenerated every deploy from GH Secrets
    ├── data/
    │   └── pb_data/                    ← persistent volume, survives restarts
    ├── repo/                           ← git clone of this repo (for re-syncing on each deploy)
    ├── scripts/
    │   └── deploy-prod.sh             ← the on-server deploy runner
    └── README.md                       ← operator's guide (link to this section)

/opt/<other-project>/                  ← siblings follow the same shape
    ├── bin/
    ├── compose/
    ├── env/
    ├── secrets/
    └── data/
First-time setup on the server
  1. Install Docker + create a deploy user with SSH key access.
  2. Add the box to your Tailscale tailnet.
  3. Configure a Cloudflare Tunnel that routes your domain (e.g. fullstack.example.com) to the Tailscale hostname on port 8080.
  4. Clone the repo at /opt/gogogo-fullstack-template/repo/. A setup-server.sh helper is planned as a follow-up; for now run the manual steps: mkdir -p bin compose env secrets data/pb_data scripts.
  5. Add the GitHub Actions secrets (see .github/workflows/deploy.yml for the full list).
After setup, every push to master deploys

The workflow at .github/workflows/deploy.yml runs on every push to master and:

  1. Builds the project (lint + race tests + CSS build).
  2. Builds the production Docker image (linux/amd64 scratch) in the GH Action runner.
  3. SCPs the new binary to the server as gogogo-fullstack-template.new (atomic swap).
  4. Writes the secrets file (/opt/gogogo-fullstack-template/secrets/gogogo-fullstack-template.env) with mode 600.
  5. SSHes in and runs scripts/deploy-prod.sh which:
    • Atomically renames gogogo-fullstack-template.new → gogogo-fullstack-template and keeps the old binary as .previous.
    • Restarts the container via docker compose -f docker-compose.prod.yml up -d.
    • Waits up to 30s for /health to return 200.
  6. Prints the new container status + last 20 log lines for confirmation.

Secrets are never stored long-term on the server: every deploy re-renders /opt/gogogo-fullstack-template/secrets/gogogo-fullstack-template.env from GitHub Actions secrets. The file is chmod 600, owned by the deploy user, and overwritten on every run — there is no history of secrets on disk.

Structure (annotated by SCOPE)

cmd/web/                          🔴 CORE  Entry point (PB + goqite + SSE Hub + DagNats + NATS)
cmd/desktop/                      🔴 CORE  Wails v3 desktop/edge shell (NATS Leaf Node when NATS_LEAFNODE_URL set)
config/                           🔴 CORE  Per-environment config
  config.go                       🔴 CORE  Env vars + age secrets
  config_dev.go / config_prod.go  🔴 CORE  Build-tag defaults
db/                               🔴 CORE  PocketBase setup + seed
internal/
  secrets/                        🔴 CORE  age-decrypted secrets loader
  queue/                          🔴 CORE  goqite + SSE Hub + workers + retry + handler registry
    goqite.go                     🔴 CORE  goqite setup, schema, graceful shutdown
    ssehub.go                     🔴 CORE  register-before-enqueue, replay buffer, backpressure
    workers.go                    🔴 CORE  worker pool with context cancellation
    retry.go                      🔴 CORE  exponential backoff + jitter (retry-go v4)
    handlers.go                   🔴 CORE  HandlerRegistry: job-type to handler dispatch
  datastar/                       🟡 PLUGIN  Datastar SSE rendering helpers
  nats/                           🟡 PLUGIN  NATS JetStream + embedded server
  dagnats/                        🟡 PLUGIN  DagNats durable workflow client
  llm/                            🟡 PLUGIN  GoAI LLM SDK helpers
  collab/                         🟡 PLUGIN  Loro CRDT + DocStore + sync workers + presence
features/
  app/                            🔴 CORE  AppContext (cross-cutting deps bundle)
  auth/                           🔴 CORE  Login/logout/cookie (UI) + 🔴 middleware
  store/                          🟡 PLUGIN  EntityStore interface (PB + CRDT strategies)
  todo/                           🟢 FEATURE  Todo MVC example (keep as reference)
    handlers/                       HTTP + SSE handlers, onboarding
    components/                     Templ components
  whiteboard/                     🟢 FEATURE  Collaborative canvas (remove if not needed)
web/resources/                    🔴 CORE  Static assets (embedded JS)
router/                           🔴 CORE  Route wiring (central dependency graph)
Key configuration constants
Constant Location Default Purpose
DefaultReplayBufferSize config/config.go 64 Per-client SSE replay ring-buffer size (was internal/queue/ssehub.go)
DefaultClientQueueSize config/config.go 64 Per-client SSE channel buffer (was internal/queue/ssehub.go)
DefaultSSEHeartbeatInterval config/config.go 15s SSE heartbeat interval (was internal/queue/ssehub.go)
OfflineSync.Enabled config/config.go true Toggle hybrid offline-sync-online. Set OFFLINE_SYNC_ENABLED=false to opt out.
DefaultBaseURL (GoAI) internal/llm/goai.go https://api.openai.com/v1 OpenAI-compatible base URL
DefaultModel (GoAI) internal/llm/goai.go gpt-4o-mini Default LLM model

All are configurable in one place (env var in config/config.go, runtime constant in config/config.go or the owning package). Change it once, every feature picks up the new value.

Why some constants are NOT in config.go? Runtime constants that are implementation details of a single package (like DefaultBaseURL in internal/llm/goai.go) stay in that package to keep cohesion. config/config.go documents every env var and the most commonly tuned runtime constants.

Acknowledgements & inside jokes

This template was inspired by northstar by Nicholas Zanghi — a Go + NATS + Datastar + Templ + DaisyUI application starter.

License, feedback

Licensed under the MIT License. This project is open to feedback, PRs, and adaptations. If something doesn't make sense, if the stack doesn't fit your problem, or if you have a better idea — open an issue.


Made with intent to be useful, not to be right. — feedback, PRs, and adaptations welcome.

Documentation

Overview

SCOPE:core - DO NOT REMOVE - Module root package. Package gogogofullstacktemplate is the module root.

This file exists so the module root package is never empty: some Go toolchains treat an empty root package as a hard "no Go files" error when running `go test ./...`, which aborts the whole test gate. With this file present the root reports "[no test files]" instead.

The package name intentionally has no underscore: golangci-lint's revive linter rejects underscore-separated package names, and the module import path (github.com/calionauta/gogogo-fullstack-template) is independent of the package name, so renaming is safe.

Directories

Path Synopsis
cmd
desktop command
Command desktop builds the Wails v3 native app shell around the exact same Go backend the web app uses (PocketBase + goqite queue + router + handlers).
Command desktop builds the Wails v3 native app shell around the exact same Go backend the web app uses (PocketBase + goqite queue + router + handlers).
web command
SCOPE:core - DO NOT REMOVE - Server entry point.
SCOPE:core - DO NOT REMOVE - Server entry point.
SCOPE:core - DO NOT REMOVE - Server configuration (env vars, secrets).
SCOPE:core - DO NOT REMOVE - Server configuration (env vars, secrets).
SCOPE:plugin - REMOVE if you don't need idempotent offline replay.
SCOPE:plugin - REMOVE if you don't need idempotent offline replay.
features
app
Package app provides the cross-cutting "application core" pattern for projects built on top of gogogo-fullstack-template.
Package app provides the cross-cutting "application core" pattern for projects built on top of gogogo-fullstack-template.
auth
SCOPE:core - DO NOT REMOVE - Login/logout with PocketBase + gogogo_auth cookie.
SCOPE:core - DO NOT REMOVE - Login/logout with PocketBase + gogogo_auth cookie.
store
SCOPE:core - EntityStore interface (the contract every storage strategy implements).
SCOPE:core - EntityStore interface (the contract every storage strategy implements).
store/pbstore
SCOPE:plugin - REMOVE if you don't need PocketBase-backed storage.
SCOPE:plugin - REMOVE if you don't need PocketBase-backed storage.
todo
SCOPE:feature - REMOVE if not using the Todo MVC example.
SCOPE:feature - REMOVE if not using the Todo MVC example.
todo/components
templ: version: v0.3.1020
templ: version: v0.3.1020
todo/handlers
SCOPE:feature - REMOVE if not using the Todo MVC example.
SCOPE:feature - REMOVE if not using the Todo MVC example.
whiteboard
SCOPE:feature - REMOVE if not using whiteboard.
SCOPE:feature - REMOVE if not using whiteboard.
internal
collab
SCOPE:core - REMOVE if not using CRDT collaboration.
SCOPE:core - REMOVE if not using CRDT collaboration.
components
SCOPE:core - JSON marshalling helpers for Datastar component attributes.
SCOPE:core - JSON marshalling helpers for Datastar component attributes.
dagnats
SCOPE:core - REMOVE if not using durable workflows.
SCOPE:core - REMOVE if not using durable workflows.
datastar
SCOPE:core - DO NOT REMOVE - Datastar rendering helpers (RenderAndPatch, MergeSignals).
SCOPE:core - DO NOT REMOVE - Datastar rendering helpers (RenderAndPatch, MergeSignals).
llm
SCOPE:core - REMOVE if not using LLM.
SCOPE:core - REMOVE if not using LLM.
llm/fakeserver
Package fakeserver provides an httptest-compatible OpenAI Chat Completions fake for end-to-end tests of the GoAI integration without burning real tokens.
Package fakeserver provides an httptest-compatible OpenAI Chat Completions fake for end-to-end tests of the GoAI integration without burning real tokens.
nats
SCOPE:core - REMOVE if not using NATS.
SCOPE:core - REMOVE if not using NATS.
queue
SCOPE:core - DO NOT REMOVE - Job queue (goqite) + SSE Hub + Worker Pool.
SCOPE:core - DO NOT REMOVE - Job queue (goqite) + SSE Hub + Worker Pool.
secrets
SCOPE:core - DO NOT REMOVE - Secret management with age (age-encryption.org).
SCOPE:core - DO NOT REMOVE - Secret management with age (age-encryption.org).
server
SCOPE:core - DO NOT REMOVE - Server bootstrap (PocketBase, queue, router).
SCOPE:core - DO NOT REMOVE - Server bootstrap (PocketBase, queue, router).
SCOPE:core - /api discovery handler.
SCOPE:core - /api discovery handler.
web
resources
SCOPE:core - DO NOT REMOVE - Embedded static assets (CSS, JS, etc.).
SCOPE:core - DO NOT REMOVE - Embedded static assets (CSS, JS, etc.).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL