| name | Andromeda Stack |
| description | Scaffold a Go CRUD web app using net/http + html/template + modernc.org/sqlite + the shared andromeda pkg packages (auth, web, config, sqlite, darkmatter). Use when the user wants to build a new Go web server with CRUD operations in the andromeda monorepo. |
Go Andromeda Web App
Overview
Scaffold and build Go CRUD web apps in the andromeda workspace using the
standard library net/http mux, html/template, modernc.org/sqlite (pure
Go, no cgo) and the shared pkg/* packages. Each app ships a single Go
binary with HTML pages, a JSON API, optional session or API key auth, embedded
templates + static assets via embed.FS, and Docker deployment.
Apps live under apps/<name>/. Each app is its own Go module. Shared packages
live under pkg/ and are each their own module, wired in via local
replace directives.
Shared crates:
pkg/auth — session Store, RequireSession / RequireAPIKey /
RequireBearerOrSession middleware, SecureEqual, VerifyPassword
(bcrypt or plaintext), GenerateSessionToken, GenerateShortID.
pkg/web — Render, WriteJSON, WriteError, DecodeJSON,
EmbeddedHandler, RedirectWithError, RedirectWithSuccess, PathInt64.
pkg/config — LoadDotEnv, Getenv, GetenvInt, GetenvBool.
pkg/sqlite — Open(path, schema) opens SQLite with the project
defaults (PRAGMA foreign_keys=ON, SetMaxOpenConns(1)).
pkg/darkmatter — embedded shared CSS + fonts plus Mount(mux, "/assets") to register routes on any *http.ServeMux.
Project Structure
apps/app-name/
├── main.go # entry point; loads env, opens DB, builds App, starts server
├── app.go # App struct + embed.FS + page-data structs
├── routes.go # *http.ServeMux wiring + middleware
├── db.go # schema, model, CRUD funcs (or thin wrapper around internal/store)
├── handlers_web.go # HTML handlers (login, index, CRUD pages)
├── handlers_api.go # JSON API handlers
├── templates/ # html/template files
│ ├── base.html # layout with {{ block "title" . }} / {{ block "content" . }}
│ └── *.html # pages that {{ template "base" . }} or define blocks
├── static/ # CSS, JS, images embedded via embed.FS
├── .env.example
├── Dockerfile # multi-stage Go build, built from repo root
├── docker-compose.yml # app-local dev compose
├── go.mod / go.sum
└── README.md
Apps with extra surface (e.g. jotts has tui/ + cmd_*.go subcommands,
feeds has subscriptions.go + background poller) layer files on top of this
shape; do not invent new layers unless the app needs them.
go.mod
Module path: github.com/stevedylandev/andromeda/apps/<app-name>. Pin
go 1.25 (match other apps). Pull the shared crates by their canonical paths
and add replace directives for the local checkout:
module github.com/stevedylandev/andromeda/apps/APP_NAME
go 1.25
require (
github.com/stevedylandev/andromeda/pkg/auth v0.0.0
github.com/stevedylandev/andromeda/pkg/config v0.0.0
github.com/stevedylandev/andromeda/pkg/darkmatter v0.0.0
github.com/stevedylandev/andromeda/pkg/sqlite v0.0.0
github.com/stevedylandev/andromeda/pkg/web v0.0.0
)
replace (
github.com/stevedylandev/andromeda/pkg/auth => ../../pkg/auth
github.com/stevedylandev/andromeda/pkg/config => ../../pkg/config
github.com/stevedylandev/andromeda/pkg/darkmatter => ../../pkg/darkmatter
github.com/stevedylandev/andromeda/pkg/sqlite => ../../pkg/sqlite
github.com/stevedylandev/andromeda/pkg/web => ../../pkg/web
)
Add pkg/tui only if the app ships a TUI. Add golang.org/x/crypto,
yuin/goldmark, etc. only when the specific app needs them. The Dockerfile
must copy pkg/ into the build context for the replace directives.
Do NOT add ORMs, web frameworks (gin, echo, chi), connection pools, or HTTP
client libs unless explicitly requested. The stdlib net/http mux (with
method-prefixed patterns like GET /notes/{short_id}) is sufficient.
main.go
Minimal — loads env, sets up logging + DB + sessions, starts the server.
package main
import (
"html/template"
"log"
"log/slog"
"net/http"
"os"
"github.com/stevedylandev/andromeda/pkg/auth"
"github.com/stevedylandev/andromeda/pkg/config"
)
func main() {
config.LoadDotEnv(".env")
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
dbPath := config.Getenv("APP_DB_PATH", "app.sqlite")
db, err := openDB(dbPath)
if err != nil {
log.Fatal(err)
}
defer db.Close()
sessions := &auth.Store{
DB: db,
CookieName: "session",
CookieSecure: config.GetenvBool("COOKIE_SECURE", false),
}
if err := sessions.EnsureSchema(); err != nil {
log.Fatal(err)
}
sessions.PruneExpired()
tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
password := os.Getenv("APP_PASSWORD")
if password == "" {
logger.Warn("APP_PASSWORD not set, using default 'changeme'")
password = "changeme"
}
apiKey := os.Getenv("APP_API_KEY")
if apiKey == "" {
logger.Info("APP_API_KEY not set, /api/* will return 403")
}
app := &App{
DB: db,
Log: logger,
Templates: tmpl,
Sessions: sessions,
Password: password,
APIKey: apiKey,
CookieSecure: sessions.CookieSecure,
}
addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
logger.Info("APP_NAME server running", "addr", addr)
if err := http.ListenAndServe(addr, app.routes()); err != nil {
log.Fatal(err)
}
}
Apps that also expose CLI subcommands (e.g. jotts, sipp) keep main.go
as a dispatcher and move runServer into cmd_server.go.
app.go — App struct + embed
Holds dependencies, embedded FS, and per-page data structs.
package main
import (
"database/sql"
"embed"
"html/template"
"log/slog"
"github.com/stevedylandev/andromeda/pkg/auth"
)
var appFS embed.FS
type App struct {
DB *sql.DB
Log *slog.Logger
Templates *template.Template
Sessions *auth.Store
Password string
APIKey string
CookieSecure bool
}
type indexPageData struct {
Items []Item
}
type loginPageData struct {
Error string
}
Page-data structs go here too. Keep names like <page>PageData.
routes.go
Wire routes on a *http.ServeMux using method-prefixed patterns. Mount
darkmatter assets and static files. Wrap protected routes with
Sessions.RequireSession / auth.RequireAPIKey.
package main
import (
"net/http"
"github.com/stevedylandev/andromeda/pkg/auth"
"github.com/stevedylandev/andromeda/pkg/darkmatter"
"github.com/stevedylandev/andromeda/pkg/web"
)
func (a *App) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
darkmatter.Mount(mux, "/assets")
requireSession := func(next http.HandlerFunc) http.HandlerFunc {
return a.Sessions.RequireSession("/login", next)
}
requireAPIKey := func(next http.HandlerFunc) http.HandlerFunc {
return auth.RequireAPIKey(a.APIKey, next)
}
mux.HandleFunc("GET /login", a.loginGetHandler)
mux.HandleFunc("POST /login", a.loginPostHandler)
mux.HandleFunc("GET /logout", a.logoutHandler)
mux.HandleFunc("GET /{$}", requireSession(a.indexHandler))
mux.HandleFunc("GET /items/new", requireSession(a.newItemGetHandler))
mux.HandleFunc("POST /items", requireSession(a.createItemHandler))
mux.HandleFunc("GET /items/{short_id}", requireSession(a.viewItemHandler))
mux.HandleFunc("GET /items/{short_id}/edit", requireSession(a.editItemGetHandler))
mux.HandleFunc("POST /items/{short_id}", requireSession(a.updateItemHandler))
mux.HandleFunc("POST /items/{short_id}/delete", requireSession(a.deleteItemHandler))
mux.HandleFunc("GET /api/items", requireAPIKey(a.apiListItems))
mux.HandleFunc("POST /api/items", requireAPIKey(a.apiCreateItem))
mux.HandleFunc("GET /api/items/{short_id}", requireAPIKey(a.apiGetItem))
mux.HandleFunc("PUT /api/items/{short_id}", requireAPIKey(a.apiUpdateItem))
mux.HandleFunc("DELETE /api/items/{short_id}", requireAPIKey(a.apiDeleteItem))
return mux
}
Notes:
GET /{$} matches exactly / (no prefix matching).
- Path params extracted with
r.PathValue("short_id").
- For HTML forms (no JS), delete via
POST /items/{short_id}/delete. The
/api/* routes use real DELETE/PUT.
db.go (or internal/store)
Single-file module: schema, model, CRUD. pkg/sqlite.Open handles the
driver, pragmas, and schema bootstrap.
package main
import (
"database/sql"
"errors"
"github.com/stevedylandev/andromeda/pkg/auth"
"github.com/stevedylandev/andromeda/pkg/sqlite"
)
type Item struct {
ID int64 `json:"id"`
ShortID string `json:"short_id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ItemInput struct {
Title string `json:"title"`
Content string `json:"content"`
}
const itemColumns = `id, short_id, title, content, created_at, updated_at`
const schema = `
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_id TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`
func openDB(path string) (*sql.DB, error) { return sqlite.Open(path, schema) }
func scanItem(s interface{ Scan(...any) error }) (*Item, error) {
var it Item
err := s.Scan(&it.ID, &it.ShortID, &it.Title, &it.Content, &it.CreatedAt, &it.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &it, nil
}
func createItem(db *sql.DB, title, content string) (*Item, error) {
shortID, err := auth.GenerateShortID(10)
if err != nil {
return nil, err
}
res, err := db.Exec(`INSERT INTO items (short_id, title, content) VALUES (?, ?, ?)`, shortID, title, content)
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
return scanItem(db.QueryRow(`SELECT `+itemColumns+` FROM items WHERE id = ?`, id))
}
func getItemByShortID(db *sql.DB, shortID string) (*Item, error) {
return scanItem(db.QueryRow(`SELECT `+itemColumns+` FROM items WHERE short_id = ?`, shortID))
}
func listItems(db *sql.DB) ([]Item, error) {
rows, err := db.Query(`SELECT ` + itemColumns + ` FROM items ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Item
for rows.Next() {
var it Item
if err := rows.Scan(&it.ID, &it.ShortID, &it.Title, &it.Content, &it.CreatedAt, &it.UpdatedAt); err != nil {
return nil, err
}
out = append(out, it)
}
return out, rows.Err()
}
func updateItemByShortID(db *sql.DB, shortID, title, content string) (*Item, error) {
res, err := db.Exec(`UPDATE items SET title=?, content=?, updated_at=datetime('now') WHERE short_id=?`, title, content, shortID)
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, nil
}
return getItemByShortID(db, shortID)
}
func deleteItemByShortID(db *sql.DB, shortID string) (bool, error) {
res, err := db.Exec(`DELETE FROM items WHERE short_id = ?`, shortID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
Conventions:
- Short IDs via
auth.GenerateShortID(10).
- Model fields use
json:"..." tags so the same struct serializes for the API.
- "Not found" returns
(nil, nil), not an error — handlers check if x == nil.
- DB path comes from
<APP>_DB_PATH env (e.g. JOTTS_DB_PATH).
- For more complex apps, move this into
internal/store/ and keep db.go as
thin pass-through wrappers (see apps/jotts).
handlers_web.go
HTML handlers. Use web.Render for templates and web.RedirectWithError for
flash-style errors via query params.
package main
import (
"net/http"
"strings"
"github.com/stevedylandev/andromeda/pkg/auth"
"github.com/stevedylandev/andromeda/pkg/web"
)
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
}
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
web.RedirectWithError(w, r, "/login", "Invalid request")
return
}
if !auth.SecureEqual(r.FormValue("password"), a.Password) {
web.RedirectWithError(w, r, "/login", "Invalid password")
return
}
token, err := a.Sessions.Create()
if err != nil {
a.Log.Error("create session failed", "err", err)
web.RedirectWithError(w, r, "/login", "Server error")
return
}
http.SetCookie(w, a.Sessions.SessionCookie(token))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
a.Sessions.Delete(c.Value)
}
http.SetCookie(w, a.Sessions.ClearCookie())
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (a *App) createItemHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
web.RedirectWithError(w, r, "/items/new", "Invalid request")
return
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
web.RedirectWithError(w, r, "/items/new", "Title is required")
return
}
it, err := createItem(a.DB, title, r.FormValue("content"))
if err != nil {
a.Log.Error("create item failed", "err", err)
web.RedirectWithError(w, r, "/items/new", "Failed to create item")
return
}
http.Redirect(w, r, "/items/"+it.ShortID, http.StatusSeeOther)
}
Patterns:
- Always
auth.SecureEqual for password compare (constant-time).
- Use
http.StatusSeeOther (303) for POST-redirect-GET.
- Pre-rendered HTML (e.g. markdown) goes through
template.HTML in the
page-data struct, not via |safe-style filters.
handlers_api.go
JSON API handlers. Use web.WriteJSON, web.WriteError, web.DecodeJSON.
package main
import (
"net/http"
"github.com/stevedylandev/andromeda/pkg/web"
)
func (a *App) apiListItems(w http.ResponseWriter, r *http.Request) {
items, err := listItems(a.DB)
if err != nil {
a.Log.Error("list items failed", "err", err)
web.WriteError(w, http.StatusInternalServerError, "internal error")
return
}
web.WriteJSON(w, http.StatusOK, items)
}
func (a *App) apiCreateItem(w http.ResponseWriter, r *http.Request) {
var in ItemInput
if !web.DecodeJSON(w, r, &in) {
return
}
if in.Title == "" {
web.WriteError(w, http.StatusBadRequest, "title is required")
return
}
it, err := createItem(a.DB, in.Title, in.Content)
if err != nil {
a.Log.Error("create item failed", "err", err)
web.WriteError(w, http.StatusInternalServerError, "internal error")
return
}
web.WriteJSON(w, http.StatusCreated, it)
}
func (a *App) apiGetItem(w http.ResponseWriter, r *http.Request) {
it, err := getItemByShortID(a.DB, r.PathValue("short_id"))
if err != nil {
web.WriteError(w, http.StatusInternalServerError, "internal error")
return
}
if it == nil {
web.WriteError(w, http.StatusNotFound, "not found")
return
}
web.WriteJSON(w, http.StatusOK, it)
}
Authentication
pkg/auth provides three middleware patterns. Pick based on app shape:
Session/cookie auth (web-facing apps)
Use *auth.Store for password-login web apps (jotts, feeds, bookmarks, etc).
Sessions stored in the same SQLite DB via EnsureSchema(). Routes guarded
with store.RequireSession("/login", handler).
Key API:
store.Create() (token, err) — issue new session row.
store.SessionCookie(token) *http.Cookie — HttpOnly, SameSite=Strict,
7-day MaxAge.
store.ClearCookie() — MaxAge=-1 cookie for logout.
store.HasValid(r) — check the incoming request's cookie.
store.PruneExpired() — call at boot to GC stale rows.
API key auth (header-based)
auth.RequireAPIKey(expectedKey, next). Reads X-API-Key header,
SecureEqual compare. Returns 403 if expectedKey is empty (auth disabled by
config), 401 on mismatch. Use this for /api/* routes when there's also a
session-cookie web UI.
Bearer-or-Session (mixed surfaces)
auth.RequireBearerOrSession(store, expectedBearer, next) — accepts either
Authorization: Bearer <token> or a valid session cookie. Used by apps that
expose the same endpoint to a CLI client AND a logged-in browser user (e.g.
sipp, jotts upload).
Env vars
| Variable | Purpose | Default |
|---|
HOST | bind address | 127.0.0.1 (set 0.0.0.0 in Docker) |
PORT | listen port | 3000 |
<APP>_DB_PATH | SQLite path | <app>.sqlite |
<APP>_PASSWORD | session login password | none → "changeme" warning |
<APP>_API_KEY | API key for /api/* | none → 403 |
COOKIE_SECURE | HTTPS-only cookie flag | false |
App-specific env vars are prefixed with the uppercase app name
(JOTTS_PASSWORD, FEEDS_API_KEY). HOST, PORT, COOKIE_SECURE are not
prefixed.
Templates (html/template)
Templates live in templates/, parsed once at startup via
template.ParseFS(appFS, "templates/*.html") and rendered with
web.Render(t, w, "name.html", data, log).
Use {{ define "..." }} / {{ template "..." . }} for layout composition.
The base file defines block names; pages define the content blocks.
templates/base.html:
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ template "title" . }}</title>
<meta name="theme-color" content="#121113" />
<link rel="stylesheet" href="/assets/darkmatter.css">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="container">
{{ template "content" . }}
</div>
</body>
</html>
{{ end }}
templates/index.html:
{{ define "title" }}Items{{ end }}
{{ define "content" }}
{{ range .Items }}
<a href="/items/{{ .ShortID }}">{{ .Title }}</a>
{{ end }}
{{ end }}
{{ template "base" . }}
Notes:
- Link CSS via
/static/styles.css (app-local) and /assets/darkmatter.css
(shared theme served by darkmatter.Mount).
- Forms POST to web routes, not
/api/*.
- Pass pre-rendered HTML as
template.HTML(...); html/template auto-escapes
everything else.
- Flash errors via
?error=... query param + web.RedirectWithError /
RedirectWithSuccess.
Static assets
Embedded via //go:embed templates/*.html static/* on a single appFS.
Serve under /static/ via web.EmbeddedHandler(appFS, "static"). Shared
theme files (CSS + fonts) come from darkmatter.Mount(mux, "/assets").
Logging (slog)
Standard library log/slog. Build once in main:
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
Pass it into App and pass it to web.Render (it logs template errors).
Use logger.Error("msg", "err", err), logger.Warn(...), logger.Info(...).
Dockerfile
Multi-stage, built from the repo root so pkg/ is available for the
replace directives. Pure Go (CGO_ENABLED=0) because the SQLite driver is
modernc.org/sqlite.
# Build from repo root: docker build -t APP_NAME -f apps/APP_NAME/Dockerfile .
FROM golang:1.25-bookworm AS builder
WORKDIR /app
COPY pkg/ ./pkg/
COPY apps/APP_NAME/go.mod apps/APP_NAME/go.sum ./apps/APP_NAME/
WORKDIR /app/apps/APP_NAME
RUN go mod download
COPY apps/APP_NAME/ ./
RUN CGO_ENABLED=0 go build -o /APP_NAME .
FROM debian:bookworm-slim
COPY --from=builder /APP_NAME /usr/local/bin/APP_NAME
WORKDIR /data
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["APP_NAME"]
If the app makes outbound HTTPS requests, add to the final stage:
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
If the app has CLI subcommands (jotts), set CMD ["APP_NAME", "server"].
App-local docker-compose.yml
services:
app:
build:
context: ../..
dockerfile: apps/APP_NAME/Dockerfile
ports:
- "${PORT:-3000}:${PORT:-3000}"
environment:
- APP_NAME_PASSWORD=${APP_NAME_PASSWORD:-changeme}
- APP_NAME_DB_PATH=/data/APP_NAME.sqlite
- COOKIE_SECURE=false
- HOST=0.0.0.0
- PORT=${PORT:-3000}
volumes:
- APP_NAME-data:/data
restart: unless-stopped
volumes:
APP_NAME-data:
.env.example
Always ship one listing every var the app reads, with short comments.
Wiring up a new app
A new app is not done when go run . works. Several workspace-level files
list every app explicitly and must be updated, or CI / Docker images / deploy
break silently.
1. Root docker-compose.yml (production)
Pulls images from GHCR. Add a service + named volume:
services:
APP_NAME:
image: ghcr.io/stevedylandev/andromeda/APP_NAME:latest
restart: unless-stopped
ports:
- "PORT:3000"
volumes:
- APP_NAME_data:/data
env_file: apps/APP_NAME/.env
Pick a unique host port (existing: 3000 sipp, 3030 jotts, 3456 og, 3535 posts,
3565 shrink, 4555 feeds, 4646 library, 6754 cellar). Drop the volumes /
env_file lines if stateless (see shrink, og).
If the app holds data worth backing up, mount its volume read-only into the
backup service.
2. .github/workflows/docker.yml
Two spots — both list every app:
ALL='["backup","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp","APP_NAME"]'
for app in backup bookmarks cellar easel feeds jotts library og posts shrink sipp APP_NAME; do
3. .github/workflows/docker-test.yml
Same two lists — keep in lockstep with docker.yml.
4. App-local docker-compose.yml
Per-app dev compose (see above).
Useful commands
Per-app (from apps/<app>/):
go mod tidy
go build ./...
go vet ./...
go test ./...
go run .
Repo-wide (from root):
make go-check
make go-test
make go-vet
make go-fmt
make go-app-test APP=APP_NAME
make go-app-vet APP=APP_NAME
make go-app-fmt APP=APP_NAME
The shared crates (pkg/web, pkg/auth, pkg/config,
pkg/sqlite, pkg/darkmatter) are each their own module — run
go build ./... inside any to check in isolation.
Checklist
When scaffolding a new app:
mkdir apps/APP_NAME && cd apps/APP_NAME && go mod init github.com/stevedylandev/andromeda/apps/APP_NAME
- Add shared-crate
require + replace directives to go.mod (auth, config, sqlite, web, darkmatter).
main.go — env load, DB open, sessions bootstrap, App build, ListenAndServe.
app.go — App struct, //go:embed, page-data structs.
routes.go — mux wiring, middleware, /static/ + darkmatter.Mount.
db.go — schema, model, CRUD via pkg/sqlite.Open.
handlers_web.go — login/logout + HTML CRUD pages.
handlers_api.go — JSON CRUD endpoints behind RequireAPIKey.
templates/base.html + per-page templates using {{ define }} blocks.
static/styles.css + any app-specific assets.
.env.example listing every env var.
Dockerfile (multi-stage, CGO_ENABLED=0) + app-local docker-compose.yml.
- Add service + volume to root
docker-compose.yml (+ backup mount if stateful).
- Add app name to both
ALL array + for app in ... loops in
.github/workflows/docker.yml AND .github/workflows/docker-test.yml.
go mod tidy, then make go-app-vet APP=APP_NAME and make go-app-test APP=APP_NAME.
docker build -f apps/APP_NAME/Dockerfile . from repo root to verify image.
What NOT to include
- No web frameworks (gin, echo, chi, fiber) — stdlib
net/http mux is enough.
- No ORMs — raw
database/sql + ? placeholders.
- No connection pools —
sqlite.Open sets MaxOpenConns(1) on purpose.
- No cgo SQLite drivers — use
modernc.org/sqlite via pkg/sqlite.
- No external CSS frameworks unless specified — use
pkg/darkmatter.
- No JS frontend / build step —
html/template rendered server-side.
- No CLI / TUI deps (cobra, bubbletea) unless the app actually needs them.
- No
replace directives for anything outside pkg/.