Keep the database in ./data and bind-mount it

SQLite writes three files — the database plus its -wal and -shm companions.
Putting them in one directory means a deployment mounts a single path and a
backup copies a single directory.

- FOODSTER_DB defaults to ./data/foodster.db, and openDB creates the parent
  directory on startup rather than failing on a fresh checkout
- compose bind-mounts ./data instead of using a named volume, so the file can
  be listed, copied and opened with any sqlite client without going through
  the container engine
- the image runs as UID 65534, which cannot write to a host directory owned
  by someone else, so compose now sets user: from FOODSTER_UID/FOODSTER_GID
- `make up` creates ./data first: left to the engine it appears root-owned
  and the app silently cannot write to it
This commit is contained in:
Esa Kataja
2026-09-05 18:17:14 +03:00
parent c9a63bdd9e
commit c6f44bb427
7 changed files with 45 additions and 15 deletions
+6
View File
@@ -11,6 +11,12 @@ FOODSTER_PASSWORD=changeme
# Host port to publish on.
FOODSTER_PORT=8080
# The database lives in ./data, bind-mounted into the container. These must
# match whoever owns that directory on the host, or the container cannot
# write to it. `id -u` and `id -g` will tell you.
FOODSTER_UID=1000
FOODSTER_GID=1000
# Used for every calendar-day calculation. Set it in development too: under
# UTC the date rolls over three hours late, which is exactly when dinner
# gets logged.
+2 -1
View File
@@ -7,7 +7,8 @@
# Generated by `templ generate` during the container build.
*_templ.go
# Local database
# The database and its SQLite companions.
/data/
*.db
*.db-shm
*.db-wal
+3 -4
View File
@@ -31,10 +31,8 @@ build: generate ## Build ./foodster
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG)
run: generate ## Run locally on :8080
FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} \
FOODSTER_DB=$${FOODSTER_DB:-./foodster.db} \
go run $(PKG)
run: generate ## Run locally on :8080 (database in ./data)
FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} go run $(PKG)
seed: ## Import a dish bundle (SEED=seeds/testi.json)
go run $(PKG) -import $(SEED)
@@ -84,6 +82,7 @@ push: ## Push the newest tag and :latest
release: image push ## Build, tag and push in one go
up: ## Start the stack
@mkdir -p data # or the engine creates it root-owned and the app cannot write
$(COMPOSE) up -d
down: ## Stop the stack
+8 -2
View File
@@ -342,13 +342,19 @@ on the server and run with Docker Compose.
static binary; the runtime stage is `FROM scratch` holding only that
binary, running as UID 65534.
- **Compose**: a single service. No database container — SQLite lives at
`/data/foodster.db` on a named volume. `restart: unless-stopped`.
`/data/foodster.db`, bind-mounted from `./data` on the host rather than
kept in a named volume, so the file can be listed and copied without going
through the container engine. Backup is `cp -r data`. Because the image
runs as UID 65534, compose sets `user:` from `FOODSTER_UID`/`FOODSTER_GID`
to match whoever owns that directory. `restart: unless-stopped`.
- **Configuration**, entirely through environment variables (see
`.env.example`):
- `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates.
- `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to
start without it.
- `FOODSTER_DB` — database file path, default `/data/foodster.db`.
- `FOODSTER_DB` — database file path, default `./data/foodster.db`. The
directory is created on startup if missing.
- `FOODSTER_UID` / `FOODSTER_GID` — host owner of `./data`.
- `TZ` — default `Europe/Helsinki`.
- The registry hostname exists only in `.env`, which is gitignored, because
§11 leaves open the possibility of publishing this repository.
+9 -3
View File
@@ -105,7 +105,8 @@ Everything is environment variables. `.env` is gitignored; start from
| Variable | Default | Purpose |
|---|---|---|
| `FOODSTER_PASSWORD` | *required* | Shared password. The app will not start without it. |
| `FOODSTER_DB` | `/data/foodster.db` | SQLite file path. |
| `FOODSTER_DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
| `FOODSTER_UID` / `FOODSTER_GID` | `1000` | Host owner of `./data`, for the bind mount. |
| `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. |
| `FOODSTER_REPO` | *required to build* | Image repository, no tag. |
| `FOODSTER_TAG` | `latest` | Tag to run under compose. |
@@ -129,8 +130,13 @@ Versions are CalVer — `vYYYYMMDD-N`, where `N` is the Nth build that day. The
running version is served at `GET /healthz`, which is the one route outside
authentication.
There is no database container. SQLite lives on a named volume, so a backup
is a file copy.
There is no database container. SQLite lives in `./data`, bind-mounted into
the container, so a backup is `cp -r data` and you can inspect the file with
any sqlite client without going through the engine.
That directory must exist and be owned by the user compose runs as — `make up`
creates it, and `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who
that is. Get them from `id -u` and `id -g`.
## Security
+11 -1
View File
@@ -18,6 +18,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -36,7 +37,10 @@ var version = "dev"
const (
listenAddr = ":8080"
defaultTZ = "Europe/Helsinki"
defaultDB = "./foodster.db"
// Everything SQLite writes — the database plus its -wal and -shm
// companions — lives in one directory, so a deployment mounts a single
// path and a backup copies a single directory.
defaultDB = "./data/foodster.db"
// failDelay throttles password guessing.
// ponytail: a fixed sleep is enough for a LAN-only app; swap in
@@ -108,6 +112,12 @@ func run() error {
// openDB opens the SQLite file and brings its schema up to date.
func openDB(path string) (*sql.DB, error) {
if dir := filepath.Dir(path); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create %s: %w", dir, err)
}
}
dsn := "file:" + path +
"?_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(ON)" +
+6 -4
View File
@@ -2,6 +2,11 @@ services:
app:
image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest}
restart: unless-stopped
# A bind mount rather than a named volume: the database sits in ./data on
# the host, where it can be listed, copied and backed up without going
# through the container engine. The image runs as UID 65534, so the
# container has to be told which host user owns that directory.
user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}"
ports:
- "${FOODSTER_PORT:-8080}:8080"
environment:
@@ -9,7 +14,4 @@ services:
FOODSTER_DB: /data/foodster.db
TZ: ${TZ:-Europe/Helsinki}
volumes:
- foodster-data:/data
volumes:
foodster-data:
- ./data:/data