Architecture Manual
This document describes the architecture of snackbox: its component structure and deployment topology. The product requirements that drive these decisions are defined in PRD.md.
Snackbox aims to be fully compliant with the 12-factor app methodology. See 12FACTOR.md for the current compliance evaluation.
Components
Component View
graph TD
Client([Client])
subgraph server["server · :8080 (Go net/http)"]
MW["Middleware\nSecurity Headers → CORS → Logging → Max Body"]
Router["Router\n(Auth · Rate Limit applied per route)"]
AuthH["Auth Handler"]
HealthH["Health Handler"]
ResourceH["Resource Handlers"]
VersionH["Version Handler"]
AuthSvc["Auth Service"]
Repos["Repository\nTagRepo · PageRepo · PostRepo · UserRepo · MediaRepo\nSettingsRepo · NavigationRepo · SocialAccountRepo · RefreshTokenRepo"]
end
subgraph metricsMux["metricsMux · :9091 (Go net/http)"]
MetricsH["Metrics Handler\n(Prometheus registry)"]
end
subgraph storage["Storage"]
SQLite[("SQLite (file)")]
Disk[("Storage (disk)")]
end
Client --> MW
MW --> Router
Router --> AuthH
AuthH --> AuthSvc
AuthSvc --> Repos
Router --> ResourceH
ResourceH --> Repos
Repos --> SQLite
ResourceH --> Disk
Router --> HealthH
Router --> VersionH
HealthH --> SQLite
The binary is structured in strict layers. Each layer only calls the layer directly below it. This enforces the architectural constraint that business logic must not appear in the router or middleware:
| Layer | Packages | Responsibility |
|---|---|---|
| Entry point | cmd/server | Wires all components, starts HTTP servers, handles OS signals |
| Middleware | internal/middleware | Security headers, CORS, auth (JWT - applied per-route), logging, rate-limit, max-body |
| Router | internal/router | Registers all routes, applies middleware chain |
| Handlers | internal/handlers | Parses requests, calls services and repositories, writes responses |
| Service | internal/service | Domain business logic: auth orchestration, token issuance, password management |
| Repositories | internal/repository | SQL queries against SQLite |
| Bootstrap | internal/bootstrap | Applies pending migrations, creates initial admin, seeds settings on first run |
| Build metadata | internal/buildinfo | VCS revision, release tag, and schema version; served by the version endpoint |
| Storage | SQLite file + filesystem | Persistent state |
A second HTTP server runs on METRICS_ADDR (default :9091) and exposes only GET /metrics. It is wired directly in cmd/server/app.go and shares no middleware with the main API. This satisfies the requirement that the metrics endpoint must not be exposed on the public API port.
Bootstrap
On every startup internal/bootstrap applies pending SQL migrations embedded in the binary via //go:embed (no external migration path required), creates the initial admin user from the ADMIN_NAME / ADMIN_EMAIL / ADMIN_PASSWORD environment variables if none exists, and seeds the site settings row with language defaulting to "en" if it has not yet been created. When SEED_CONTENT=true, bootstrap also creates example posts, pages, tags, and navigation items on first run so a fresh instance has browsable demo content immediately - disabled by default so production deployments do not receive demo content without an explicit opt-in. This satisfies the requirement that migrations are applied automatically on startup.
Configuration
All runtime configuration is loaded by internal/config from environment variables, with an optional .env file as a fallback. See the Administrator Manual for the full reference.
Authentication and Session Model
Snackbox uses a stateful two-token model. This is a conscious design decision that balances statelessness (12-factor VI) with real-world usability.
Tokens
| Token | Format | Lifetime | Storage |
|---|---|---|---|
| Access token | Signed JWT (HS256) | JWT_EXPIRY (default 15 m) | Client only - never stored server-side |
| Refresh token | Opaque random hex (32 bytes) | JWT_REFRESH_EXPIRY (default 7 days) | SHA-256 hash stored in refresh_tokens table |
The raw refresh token is generated with crypto/rand, hex-encoded, and returned to the client once. Only its SHA-256 digest is persisted - a database breach exposes no usable tokens.
Flow
- Login (
POST /api/v1/auth/login) - validates credentials, issues both tokens, inserts arefresh_tokensrow. - Authenticated requests - client sends
Authorization: Bearer <access_token>. The middleware validates the JWT signature and expiry; no database lookup is needed. - Refresh (
POST /api/v1/auth/refresh) - client submits the refresh token. The server hashes it, looks up the row, checks expiry, deletes the old row, and issues a new token pair (rotation). The old refresh token is immediately invalidated. - Logout (
POST /api/v1/auth/logout) - client submits the refresh token. The server deletes the row. NoAuthorizationheader is required. - Logout-all (
POST /api/v1/auth/logout-all) - requires a valid access token. The server deletes allrefresh_tokensrows for the authenticated user, terminating every active session across all devices.
Why logout does not require the access token
Possession of the refresh token is the proof of identity for logout. An anonymous attacker cannot log out arbitrary users because they would need to guess a 32-byte random value (2²⁵⁶ search space). If an attacker already holds the refresh token they have full session access - being able to also revoke it is not a meaningful escalation. This pattern is codified in RFC 7009 (OAuth 2.0 Token Revocation).
Logout is idempotent: submitting an already-invalidated or unknown token returns 204 No Content without error, so clients do not need to handle "already logged out" edge cases.
Stateful state belongs in the database (12-factor VI)
Storing refresh tokens in SQLite is fully 12-factor compliant. Factor VI ("stateless processes") requires that state not be held in process memory between requests - it does not prohibit stateful backing services. The refresh_tokens table is the backing service for session state, exactly as intended by the 12-factor model.
Deployment
Deployment View
flowchart TD
Client([Client])
Prometheus([Prometheus])
RP["Reverse Proxy\nnginx · Caddy · …"]
subgraph host["Container / Host"]
API["snackbox · :8080 · API\n/usr/local/bin/snackbox"]
Metrics["snackbox · :9091 · Metrics\n/usr/local/bin/snackbox"]
DB[("Database\n$DATABASE_PATH")]
Media[("Media Storage\n$STORAGE_PATH")]
end
Client -->|"80/TCP"| RP
RP -->|"8080/TCP"| API
Prometheus -->|"9091/TCP"| Metrics
API --> DB
API --> Media
Snackbox is deployed as a single process behind a reverse proxy. The reverse proxy (nginx, Caddy, or similar) handles TLS termination and forwards requests to the API port. The metrics port is kept on loopback or an internal monitoring network. See Deployment Constraints for platform and resource requirements.
| Port | Default | Exposure |
|---|---|---|
LISTEN_ADDR | :8080 | Via reverse proxy (public) |
METRICS_ADDR | :9091 | Internal / loopback only |
Persistent state lives in two locations:
| Path | Content |
|---|---|
DATABASE_PATH | SQLite database file |
STORAGE_PATH | Uploaded media files |
Both paths should reside on the same volume so that a single backup covers all state. See the Administrator Manual for deployment guides (systemd and Docker).
Tools and Frameworks
Key runtime dependencies
These are the libraries that shape architectural decisions. Versions are tracked in the SBOM below.
| Library | Rationale |
|---|---|
net/http | HTTP server - standard library, no framework; keeps the dependency surface minimal |
mattn/go-sqlite3 | SQLite driver via CGO; chosen for maturity and broad platform support |
golang-jwt/jwt/v5 | JWT signing and validation for access tokens |
spf13/cobra | CLI framework for subcommands and flag parsing |
golang.org/x/crypto | bcrypt password hashing |
golang.org/x/time/rate | Token-bucket rate limiter applied to the login endpoint |
prometheus/client_golang | Prometheus metrics instrumentation |
Development and CI tools
These tools are not Go module dependencies and do not appear in the SBOM.
| Tool | Purpose |
|---|---|
golangci-lint | Static analysis - config in .golangci.yml |
vacuum | OpenAPI spec linting - config in .vacuum.conf.yaml |
hurl | End-to-end API integration tests - scenarios in test/ |
git-cliff | Changelog and release notes generation |
Dependencies and Licensing
Snackbox is released under the BSD-3-Clause License. All direct and transitive Go module dependencies use MIT, BSD-3-Clause, or Apache-2.0 - all of which are compatible with BSD-3-Clause outbound licensing and impose no copyleft or viral obligations.
Notable: mattn/go-sqlite3 bundles the SQLite C amalgamation, which is public domain and carries no license obligations.
Software Bill of Materials (SBOM)
The full dependency table, license identifiers, and instructions for regenerating the report have been extracted to docs/SBOM.md.
Architecture Decision Records
Architecture Decision Records (ADRs) document significant design choices, the context that prompted them, and the reasoning behind the decision taken. Each record is immutable once accepted - if a decision is reversed, a new ADR supersedes it rather than replacing the old one.
ADR-001 - Schema version representation: filename over PRAGMA user_version
Status: Accepted Issue: #29
Context
GET /version exposes a schema_version field populated by querying MAX(name) from the schema_migrations tracking table (database.LatestMigration()). SQLite also provides a built-in PRAGMA user_version - a 4-byte integer stored in the database file header that is readable on any SQLite file before any tables exist. Issue #29 asked whether PRAGMA user_version should be adopted alongside or instead of the filename-based approach.
Decision
Keep the filename-based approach. Do not adopt PRAGMA user_version.
Reasoning
-
The pre-migration read problem does not arise in practice. In
buildApp(),db.LatestMigration()is always called afterbootstrap.Run(), which itself callsRunMigrations(). By the time the schema version is read,schema_migrationsis guaranteed to exist. No code path in the server or any subcommand reads the schema version before migrations have run. -
The filename is more informative than a bare integer. The name
002_fix_title_image_nulls.sqlencodes both the sequence number and the semantic purpose of the migration.PRAGMA user_version = 2conveys only the number, offering no context to operators or tooling. -
Two sources of truth with no enforcement. Adopting
PRAGMA user_versionwould require every migration file to end withPRAGMA user_version = N. There is no mechanism to validate that this was done correctly: a migration that omits the statement silently leaves the header value stale while the filename-based version remains accurate. Divergence between the two values would be harder to diagnose than a simple missing statement. -
No current or planned 1.x use case requires pre-migration inspection. The scenario where
PRAGMA user_versionadds value - an external tool that inspects a.dbfile before it is attached to a running instance - has no implementation in Snackbox and none is planned for the 1.x line. If such tooling is ever built, an ADR at that point can revisit this decision with a concrete use case to justify the added maintenance cost.
ADR-002 - Docker multi-arch strategy: two independent builds with QEMU emulation
Status: Accepted Issues: #38, #43
Context
The project produces two kinds of delivery artifacts:
- Release binaries - built by the
go_buildCI job on a Debian/glibc toolchain, statically linked, published to the package registry forlinux/amd64andlinux/arm64. - Docker images - built by the
docker_buildCI job using the Dockerfile's own Alpine/musl builder stage.
Issue #43 noted that these two paths produce binaries linked against different C runtimes (glibc vs musl), with different checksums, from the same source. Three consolidation strategies were evaluated:
- Option A - Docker owns the build; release binaries are extracted from built images.
- Option B - CI owns the build; the Dockerfile has no builder stage and packages pre-built binaries.
- Option C - Accept two independent builds; document the split as policy.
Issue #38 noted that the docker_build job carried no --platform flag, meaning the image architecture silently followed whichever runner picked up the job. Two strategies were evaluated for adding multi-arch support:
- C1 - Docker Buildx with QEMU emulation; no cross-compilers required.
- C2 - Docker Buildx with
--platform=$BUILDPLATFORMand musl cross-compilers; faster builds, more complex Dockerfile.
Decision
Adopt Option C (two independent builds) combined with C1 (Buildx + QEMU emulation).
Reasoning
-
Dockerfile stays self-contained. Developers can run
docker build .on any supported platform without pre-building binaries. Removing the builder stage (Option B) would break this workflow. -
The musl/glibc split is invisible to users. Both binaries are statically linked; the C runtime is not present in the final executable. Behavior is identical. The difference only matters for binary hash comparison, which is not a user-facing concern.
-
QEMU emulation is acceptable at release cadence. The arm64 build runs under QEMU on the amd64 CI runner, adding roughly 5–10× overhead to the Go compilation step. For a project with infrequent releases this is a worthwhile trade-off for keeping the Dockerfile and CI configuration simple.
-
macOS developers benefit without any local changes. Docker Desktop on Apple Silicon (arm64) pulls the
linux/arm64layer from the multi-arch manifest natively. No local Buildx setup is required. -
C2 is a drop-in upgrade path. If build time becomes a concern, adding
--platform=$BUILDPLATFORMand the appropriate musl cross-compiler to the Dockerfile replaces QEMU emulation without changing the overall strategy or the CI job structure.
Consequences
- The
docker_buildCI job registers QEMU viatonistiigi/binfmtand usesdocker buildx build --platform linux/amd64,linux/arm64 --push. - The
publish_dockerCI job usesdocker buildx imagetools createto retag the multi-arch manifest. The previous pull/tag/push pattern is replaced because it collapses a multi-arch manifest into a single-arch image. - Release binaries (
go_build) are unchanged. - The Dockerfile is unchanged; its Alpine/musl builder stage continues to be the source of truth for the Docker image binary.