Skip to content

Commit c00a5b4

Browse files
authored
feat(docker): default app image, standalone by docker run (#281)
* feat(docker): default app image, standalone by `docker run` The repo shipped a Celery worker image but nothing that ran the app itself, so "run the default app in Docker" meant writing a Dockerfile by hand — even though every `smpy new` scaffold gets one. Adds a root `Dockerfile` (the conventional place: `docker build .` and most PaaS auto-detect it) building host + every bundled module in one uv+Node builder stage, because the Vite build imports `modules.generated.{ts,css}` that `gen-pages` emits from the *installed Python modules*. Runtime is `python:3.12-slim`, non-root, healthchecked. Standalone means standalone: SQLite under /app/data, no Postgres and no Redis needed to boot. `docker/entrypoint.sh` applies `alembic upgrade heads` and generates ephemeral values for the three secrets production refuses to start without, so a bare `docker run -p 8000:8000` works. Two fixes were load-bearing for that: - `BackgroundTasksSettings` read neither `SM_BG_TASKS_BROKER_URL` nor `SM_BG_TASKS_RESULT_BACKEND`, so the production validator only ever saw the localhost defaults it rejects — no container with the module installed could boot, and the compose worker/beat services silently used localhost instead of the `redis` hostname they set. Both fields now resolve from env at construction; DB hydration still wins after. - worker/beat declared a required `env_file: .env`, which is gitignored — a fresh clone couldn't `docker compose` anything at all, the new app service included. Verified locally: `docker run` and `docker compose up app` both boot healthy with no other services, admin bootstrap + browser login work, the built bundle hydrates (no Vite dev-server tags), precompressed assets serve from /static, and data survives a restart on the volume. Claude-Session: https://claude.ai/code/session_01MKtkDrsfDCwZtXxbPGK3Tv * fix(docker): leave Celery out of the default app image A background queue means a second process and a broker — the opposite of what a standalone image is for. The default image was setting SM_BG_TASKS_BROKER_URL to the compose `redis` hostname purely to satisfy the production validator, advertising a dependency it never used. The build now passes `--no-install-package simple-module-background-tasks`, so the module has no entry point to discover: no broker env, no Celery settings, no admin page, and none of its pages in the bundle. Background jobs stay where they belong — the worker/beat services built from docker/worker.Dockerfile. Verified: image boots healthy with zero SM_BG_TASKS_* vars set, login and /dashboard/ work, /admin/background-tasks/ 404s, the admin settings list and sidebar no longer mention it, and `modules.generated.ts` has no background_tasks entry. Claude-Session: https://claude.ai/code/session_01MKtkDrsfDCwZtXxbPGK3Tv * feat(docker): seed a default admin when none is configured A fresh container served a login page nobody held credentials for: the users bootstrap needs both an email and a password, and unset meant no account at all. The compose file and `make docker-app` papered over it with `admin`/`admin`, which is a bad thing to bake into an image that is also meant to run on real hosts. The entrypoint now defaults `SM_USERS_BOOTSTRAP_EMAIL` to admin@example.com and, when no password is given, generates one and prints it once. Explicit values still win, and compose/make just pass the vars through instead of forcing weak ones. Safe on a reused volume by construction: the users module applies the seed only while its table is empty, so the printed password is a first-boot value and an existing account is never touched. The banner says so, and points at `smpy users create-admin --force` for recovery. Verified on a fresh volume: printed credentials log in (204), `admin`/`admin` is rejected (400), the password survives a restart unchanged, and passing both env vars logs no banner and uses them. Claude-Session: https://claude.ai/code/session_01MKtkDrsfDCwZtXxbPGK3Tv * feat(docker): default the first-boot admin password to `changeme` The image previously generated a random first-boot password and printed it once. A fixed, well-known default is the better fit for a starter image you are meant to `docker run` and log straight into: no log-scraping step, and it matches the `changeme` that `.env.example` already uses for local dev, so the container and `make dev` behave the same. The seed still only lands while the users table is empty, so a public default cannot overwrite an existing account on a reused volume. The banner is loud about the tradeoff and names the two env vars that replace it. Claude-Session: https://claude.ai/code/session_01MKtkDrsfDCwZtXxbPGK3Tv
1 parent 9ad996d commit c00a5b4

9 files changed

Lines changed: 419 additions & 9 deletions

File tree

.dockerignore

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# VCS — nothing in the build reads git metadata (every package version is
2+
# static), and .git is the single biggest thing in the context.
3+
.git/
4+
15
# Python / uv
26
__pycache__/
37
*.py[cod]
@@ -18,6 +22,10 @@ host/client_app/dist/
1822
packages/*/dist/
1923
.npm/
2024

25+
# Built frontend — the image runs its own `npm run build`, and a stale local
26+
# bundle would shadow it (same host/static/dist path).
27+
host/static/dist/
28+
2129
# Editors / OS
2230
.vscode/
2331
.idea/
@@ -28,7 +36,22 @@ packages/*/dist/
2836
*.db
2937
*.sqlite
3038
.memray/
39+
uploads/
40+
var/
41+
42+
# Agent + tooling scratch. `.claude/worktrees/` holds full checkouts of this
43+
# repo, so leaving it in would multiply the build context by every worktree.
44+
.claude/
45+
.worktrees/
46+
.emdash/
47+
.qa/
48+
.verify/
49+
.playwright-mcp/
50+
.playwright-cli/
51+
.benchmarks/
52+
qa-shots/
3153

32-
# Tests / docs not needed at runtime
54+
# Tests / docs / sample data not needed at runtime
3355
tests/e2e/
3456
docs/
57+
dataset/

Dockerfile

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# syntax=docker/dockerfile:1.7
2+
# Default image for the SimpleModule reference app — the host plus the bundled
3+
# modules that a single web process needs (auth/users, dashboard, permissions,
4+
# settings, file storage, feature flags, audit log, branding, site lock).
5+
#
6+
# Background tasks are deliberately not part of it: Celery is a second process
7+
# plus a broker, which is the opposite of a standalone image. The worker/beat
8+
# services in docker-compose.yml build docker/worker.Dockerfile for that.
9+
#
10+
# docker build -t simple-module-python .
11+
# docker run --rm -p 8000:8000 simple-module-python
12+
#
13+
# Standalone by design: SQLite under /app/data, no Postgres and no Redis
14+
# needed to boot. `docker-compose.yml`'s `app` service is the same image with
15+
# a named volume; worker/beat (Celery) stay opt-in.
16+
#
17+
# One builder stage carries both uv and Node because the Vite build imports
18+
# `modules.generated.{ts,css}`, which `smpy host gen-pages` emits from the
19+
# *installed Python modules* — a Node-only stage would have nothing to read.
20+
21+
FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS builder
22+
23+
ENV UV_LINK_MODE=copy \
24+
UV_COMPILE_BYTECODE=1 \
25+
PYTHONUNBUFFERED=1
26+
27+
WORKDIR /app
28+
29+
# Node 24 — same major as NODE_VERSION in .github/workflows/pr.yml, so the
30+
# image builds the bundle CI validates.
31+
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
32+
&& apt-get install -y --no-install-recommends nodejs \
33+
&& rm -rf /var/lib/apt/lists/*
34+
35+
# Dependency layer: every workspace member's manifest, resolved before the
36+
# full source arrives. `uv.lock` is gitignored in this repo, so it's an
37+
# optional glob and the sync deliberately isn't `--frozen`.
38+
COPY pyproject.toml uv.lock* ./
39+
COPY framework/ framework/
40+
COPY modules/ modules/
41+
COPY host/pyproject.toml host/
42+
RUN --mount=type=cache,target=/root/.cache/uv \
43+
uv sync --all-packages --no-dev --no-install-workspace
44+
45+
# npm workspaces span host/client_app, packages/* and modules/* — every
46+
# member's package.json must exist before `npm ci` will honour the lockfile.
47+
COPY package.json package-lock.json ./
48+
COPY packages/ packages/
49+
COPY host/client_app/package.json host/client_app/
50+
RUN --mount=type=cache,target=/root/.npm npm ci
51+
52+
# --no-install-package drops the Celery module from this image: with no entry
53+
# point installed, discovery never sees it, so nothing here needs a broker and
54+
# the bundle carries none of its pages. Drop the flag (and point
55+
# SM_BG_TASKS_BROKER_URL at a real Redis) to run tasks from the web process.
56+
COPY . .
57+
RUN --mount=type=cache,target=/root/.cache/uv \
58+
uv sync --all-packages --no-dev --no-install-package simple-module-background-tasks
59+
60+
# Page manifest + generated module imports first, then the production bundle
61+
# into host/static/dist (with its .vite/manifest.json and precompressed
62+
# .gz/.br siblings, which the host serves from the /static mount).
63+
# The venv binary directly rather than `uv run`, which re-resolves and re-syncs
64+
# the environment on every invocation — the layer above already installed
65+
# exactly what this image should contain.
66+
RUN /app/.venv/bin/smpy host gen-pages --host-dir=host/client_app
67+
RUN npm run build
68+
69+
# node_modules is a build-time artifact only; the runtime serves static files.
70+
RUN rm -rf node_modules host/client_app/node_modules
71+
72+
FROM python:3.12-slim-bookworm AS runtime
73+
74+
ENV PYTHONUNBUFFERED=1 \
75+
PYTHONDONTWRITEBYTECODE=1 \
76+
PATH="/app/.venv/bin:$PATH"
77+
78+
# Containers serve the built bundle; development mode would emit asset tags
79+
# pointing at a Vite dev server that isn't in this image.
80+
ENV SM_ENVIRONMENT=production
81+
82+
# Absolute sqlite path: /app/data is the volume mount point, so the DB is
83+
# cwd-independent and survives restarts whenever a volume is attached.
84+
ENV SM_DATABASE_URL=sqlite+aiosqlite:////app/data/app.db
85+
86+
# curl backs the HEALTHCHECK below.
87+
RUN apt-get update \
88+
&& apt-get install -y --no-install-recommends curl ca-certificates \
89+
&& rm -rf /var/lib/apt/lists/*
90+
91+
COPY --from=builder /app /app
92+
93+
RUN mkdir -p /app/data \
94+
&& useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin app \
95+
&& chown -R app:app /app
96+
USER app
97+
98+
WORKDIR /app
99+
EXPOSE 8000
100+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
101+
CMD curl -fsS http://localhost:8000/health || exit 1
102+
103+
ENTRYPOINT ["/app/docker/entrypoint.sh"]
104+
CMD ["uvicorn", "host.main:app", "--host", "0.0.0.0", "--port", "8000"]

Makefile

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker
1+
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker
22

33
# Install
44
install:
@@ -184,6 +184,23 @@ kill:
184184
@-lsof -ti:8000,5050,5173 | xargs kill -9 2>/dev/null
185185
@echo "Ports 8000, 5050, 5173 freed."
186186

187+
# Docker — the default app image (./Dockerfile). Standalone: SQLite inside the
188+
# container, no Postgres or Redis needed. Both run targets honour SM_APP_PORT
189+
# so they don't collide with a `make dev` already holding 8000.
190+
SM_APP_PORT ?= 8000
191+
export SM_APP_PORT
192+
193+
docker-build: ## Build the default app image
194+
docker build -t simple-module-python .
195+
196+
docker-app: docker-build ## Run the built image standalone on http://localhost:$(SM_APP_PORT)
197+
docker run --rm -p $(SM_APP_PORT):8000 \
198+
-v simple-module-python-data:/app/data \
199+
simple-module-python
200+
201+
docker-compose-app: ## Same image via compose (named volume, .env-overridable)
202+
docker compose up --build app
203+
187204
# Docker — Postgres/Redis now live in the shared ../dev-services stack.
188205
# docker-up brings that shared stack up (idempotent, shared with other repos).
189206
docker-up:

README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,72 @@ make dev
4747

4848
Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard/` is the authenticated home, and `/admin/doctor/` is the admin-only "smpy doctor" panel (static checks, migrations, dev server, modules).
4949

50+
## Run it in Docker
51+
52+
The repo ships a default image (`./Dockerfile`) that builds the host plus every
53+
bundled module — Python workspace, `gen-pages`, and the production Vite bundle —
54+
and serves it with uvicorn. It is standalone: SQLite lives inside the container
55+
under `/app/data`, so no Postgres and no Redis are needed to boot.
56+
57+
```bash
58+
make docker-app # build ./Dockerfile, then run it on http://localhost:8000
59+
```
60+
61+
Both run targets take `SM_APP_PORT=8010` when a `make dev` already holds
62+
8000.
63+
64+
or without make:
65+
66+
```bash
67+
docker build -t simple-module-python .
68+
docker run --rm -p 8000:8000 -v simple-module-python-data:/app/data simple-module-python
69+
```
70+
71+
**Logging in.** With no `SM_USERS_BOOTSTRAP_*` set, the container seeds
72+
**`admin@example.com` / `changeme`** — the same pair `.env.example` uses for
73+
local dev — and says so on every boot that uses it:
74+
75+
```
76+
entrypoint: WARNING - no SM_USERS_BOOTSTRAP_PASSWORD set, so the
77+
entrypoint: first-boot admin is the public default:
78+
entrypoint: admin@example.com / changeme
79+
```
80+
81+
Pass `-e SM_USERS_BOOTSTRAP_EMAIL=… -e SM_USERS_BOOTSTRAP_PASSWORD=…` to seed
82+
your own instead, and change it before the container is reachable by anyone but
83+
you. The seed only applies while the users table is empty, so on a reused volume
84+
the existing password still stands — reset it with
85+
`smpy users create-admin --email … --password … --force`.
86+
87+
`make docker-compose-app` (or `docker compose up --build app`) runs the same
88+
image through compose with a named volume — independent of the shared
89+
dev-services stack that `worker`/`beat` use.
90+
91+
What the entrypoint does before uvicorn binds: applies `alembic upgrade heads`
92+
(a fresh volume has no tables, and production fails boot on `SM010` when the DB
93+
is behind head), and generates an ephemeral `SM_SECRET_KEY` when none is set —
94+
enough to boot and log in, but sessions die on restart, so set one for anything
95+
real.
96+
97+
The image runs with `SM_ENVIRONMENT=production` because it serves the built
98+
bundle rather than a Vite dev server. Useful overrides:
99+
100+
| Variable | Image default | Why you'd change it |
101+
|---|---|---|
102+
| `SM_SECRET_KEY` | generated per start | Persist sessions across restarts |
103+
| `SM_DATABASE_URL` | `sqlite+aiosqlite:////app/data/app.db` | Point at Postgres |
104+
| `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` | `admin@example.com` / `changeme` | Seed a first admin nobody else can guess |
105+
| `SM_TRUSTED_PROXY` | unset | Set to `*` behind a TLS-terminating reverse proxy |
106+
107+
**No background tasks.** The image skips installing the Celery module
108+
(`uv sync … --no-install-package simple-module-background-tasks`), so nothing in
109+
it wants a broker — a queue means a second process and a Redis, which is the
110+
opposite of a standalone image. `docker-compose.yml`'s `worker` / `beat`
111+
services cover that path: they build `docker/worker.Dockerfile` against the
112+
shared `../dev-services` Postgres + Redis on the external `devnet` network. To
113+
run tasks from the web process instead, drop the `--no-install-package` flag
114+
from the Dockerfile and set `SM_BG_TASKS_BROKER_URL` / `_RESULT_BACKEND`.
115+
50116
## Create a new module
51117

52118
```bash
@@ -105,6 +171,7 @@ docs/
105171
| `make migration msg="..."` | Autogenerate a new migration |
106172
| `make new-module name=<name>` | Scaffold a new module |
107173
| `make kill` | Stop any running dev servers (ports 8000, 5050, 5173) |
174+
| `make docker-build` / `docker-app` | Build the default app image / run it standalone on port 8000 |
108175
| `make docker-up` / `docker-down` | `docker-up` brings up the shared dev-services stack (Postgres/Redis/MinIO); `docker-down` stops only this repo's worker/beat (SQLite needs no Docker) |
109176

110177
## Configuration

docker-compose.yml

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,28 @@
11
services:
2+
# The default app, built from ./Dockerfile. Self-contained: SQLite on a
3+
# named volume, no Postgres and no Redis required, so
4+
# `docker compose up --build app` works with nothing else running. The image
5+
# ships no Celery module — worker/beat below are the background-jobs half and
6+
# still expect the shared ../dev-services stack.
7+
app:
8+
build:
9+
context: .
10+
dockerfile: Dockerfile
11+
environment:
12+
# Left unset the entrypoint generates an ephemeral key (fine for a demo,
13+
# logs a warning, invalidates sessions on restart).
14+
- SM_SECRET_KEY
15+
# First-boot admin seed, applied only while the users table is empty.
16+
# Unset, the entrypoint seeds admin@example.com with a generated
17+
# password and prints it — see `docker compose logs app`.
18+
- SM_USERS_BOOTSTRAP_EMAIL
19+
- SM_USERS_BOOTSTRAP_PASSWORD
20+
ports:
21+
# SM_APP_PORT frees you from a host-port clash with `make dev`.
22+
- "${SM_APP_PORT:-8000}:8000"
23+
volumes:
24+
- appdata:/app/data
25+
226
# Postgres + Redis now come from the shared ../dev-services stack
327
# (one PostGIS + one Redis on the external `devnet` network). Start it first
428
# with `make up` in ~/Repos/dev-services. This project uses:
@@ -9,7 +33,9 @@ services:
933
build:
1034
context: .
1135
dockerfile: docker/worker.Dockerfile
12-
env_file: .env
36+
env_file:
37+
- path: .env
38+
required: false
1339
environment:
1440
SM_BG_TASKS_BROKER_URL: redis://redis:6379/4
1541
SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/5
@@ -30,7 +56,9 @@ services:
3056
build:
3157
context: .
3258
dockerfile: docker/worker.Dockerfile
33-
env_file: .env
59+
env_file:
60+
- path: .env
61+
required: false
3462
environment:
3563
SM_BG_TASKS_BROKER_URL: redis://redis:6379/4
3664
SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/5
@@ -52,3 +80,6 @@ services:
5280
networks:
5381
devnet:
5482
external: true
83+
84+
volumes:
85+
appdata:

docker/entrypoint.sh

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/bin/sh
2+
# Container entrypoint for the default app image (see ../Dockerfile).
3+
#
4+
# Three things have to happen before uvicorn binds, all of which the image can
5+
# do for itself so `docker run -p 8000:8000 <image>` is enough:
6+
#
7+
# 1. Fill in the secrets production refuses to run without. The image ships
8+
# no baked-in keys (an image everyone can pull is the worst possible
9+
# place for one), so any that are still unset get an ephemeral random
10+
# value — enough to boot and log in, gone on the next start.
11+
# 2. Seed an admin, so a bare `docker run` lands on a login you can pass
12+
# (admin@example.com / changeme unless SM_USERS_BOOTSTRAP_* say otherwise).
13+
# 3. Apply migrations. A fresh SQLite volume has no tables at all, and the
14+
# boot-time SM010 check fails the app in production when the DB revision
15+
# is behind head.
16+
set -e
17+
18+
# SM_SECRET_KEY signs session cookies; the two SM_USERS_* secrets sign
19+
# password-reset and email-verification tokens. All three reject their
20+
# placeholder default when SM_ENVIRONMENT is a production value.
21+
_ephemeral=""
22+
for _var in SM_SECRET_KEY SM_USERS_RESET_PASSWORD_TOKEN_SECRET SM_USERS_VERIFICATION_TOKEN_SECRET; do
23+
eval "_current=\${$_var:-}"
24+
if [ -z "$_current" ]; then
25+
eval "export $_var=\"\$(python -c 'import secrets; print(secrets.token_urlsafe(48))')\""
26+
_ephemeral="$_ephemeral $_var"
27+
fi
28+
done
29+
30+
if [ -n "$_ephemeral" ]; then
31+
echo "entrypoint: generated ephemeral secrets for:$_ephemeral" >&2
32+
echo "entrypoint: sessions and any reset/verification links they sign die on restart — set these to persist them." >&2
33+
fi
34+
35+
# The seed the users module applies only while its table is empty, so this
36+
# can't overwrite an account on a persistent volume — and without it a fresh
37+
# container serves a login page nobody holds credentials for. The default is
38+
# deliberately a well-known one: this image is a starting point you are meant
39+
# to log straight into. Anything running where that matters should pass both
40+
# vars, which is why the banner below is loud rather than silent.
41+
: "${SM_USERS_BOOTSTRAP_EMAIL:=admin@example.com}"
42+
: "${SM_USERS_BOOTSTRAP_PASSWORD:=changeme}"
43+
export SM_USERS_BOOTSTRAP_EMAIL SM_USERS_BOOTSTRAP_PASSWORD
44+
45+
if [ "$SM_USERS_BOOTSTRAP_PASSWORD" = "changeme" ]; then
46+
echo "entrypoint: WARNING - no SM_USERS_BOOTSTRAP_PASSWORD set, so the" >&2
47+
echo "entrypoint: first-boot admin is the public default:" >&2
48+
echo "entrypoint: $SM_USERS_BOOTSTRAP_EMAIL / changeme" >&2
49+
echo "entrypoint: Seeded only while no user exists — an existing install is" >&2
50+
echo "entrypoint: left alone. Set SM_USERS_BOOTSTRAP_EMAIL/_PASSWORD before" >&2
51+
echo "entrypoint: first boot, or change it later with:" >&2
52+
echo "entrypoint: smpy users create-admin --email ... --password ... --force" >&2
53+
fi
54+
55+
# `upgrade heads` (plural) applies every per-module migration branch;
56+
# `upgrade head` (singular) errors once a second module ships a branch label.
57+
echo "entrypoint: applying migrations..." >&2
58+
alembic -c host/alembic.ini upgrade heads
59+
60+
exec "$@"

0 commit comments

Comments
 (0)