From 11e0ba5116087f3d2f554bb36dc8078f69ee706f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 21 Aug 2026 23:07:28 +0200 Subject: [PATCH 1/4] feat(docker): default app image, standalone by `docker run` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 25 ++++- Dockerfile | 102 ++++++++++++++++++ Makefile | 21 +++- README.md | 52 +++++++++ docker-compose.yml | 34 +++++- docker/entrypoint.sh | 38 +++++++ docs/reference/deployment.md | 26 +++++ .../background_tasks/settings.py | 27 ++++- .../tests/test_bg_settings_env.py | 58 ++++++++++ 9 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 Dockerfile create mode 100755 docker/entrypoint.sh create mode 100644 modules/background_tasks/tests/test_bg_settings_env.py diff --git a/.dockerignore b/.dockerignore index be06fc56..ae8d5863 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,7 @@ +# VCS — nothing in the build reads git metadata (every package version is +# static), and .git is the single biggest thing in the context. +.git/ + # Python / uv __pycache__/ *.py[cod] @@ -18,6 +22,10 @@ host/client_app/dist/ packages/*/dist/ .npm/ +# Built frontend — the image runs its own `npm run build`, and a stale local +# bundle would shadow it (same host/static/dist path). +host/static/dist/ + # Editors / OS .vscode/ .idea/ @@ -28,7 +36,22 @@ packages/*/dist/ *.db *.sqlite .memray/ +uploads/ +var/ + +# Agent + tooling scratch. `.claude/worktrees/` holds full checkouts of this +# repo, so leaving it in would multiply the build context by every worktree. +.claude/ +.worktrees/ +.emdash/ +.qa/ +.verify/ +.playwright-mcp/ +.playwright-cli/ +.benchmarks/ +qa-shots/ -# Tests / docs not needed at runtime +# Tests / docs / sample data not needed at runtime tests/e2e/ docs/ +dataset/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..4ab9e31e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,102 @@ +# syntax=docker/dockerfile:1.7 +# Default image for the SimpleModule reference app — the host plus every +# bundled module (auth/users, dashboard, permissions, settings, background +# tasks, file storage, feature flags, audit log, branding, site lock). +# +# docker build -t simple-module-python . +# docker run --rm -p 8000:8000 simple-module-python +# +# Standalone by design: SQLite under /app/data, no Postgres and no Redis +# needed to boot. `docker-compose.yml`'s `app` service is the same image with +# a named volume; worker/beat (Celery) stay opt-in. +# +# One builder stage carries both uv and Node because the Vite build imports +# `modules.generated.{ts,css}`, which `smpy host gen-pages` emits from the +# *installed Python modules* — a Node-only stage would have nothing to read. + +FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS builder + +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Node 24 — same major as NODE_VERSION in .github/workflows/pr.yml, so the +# image builds the bundle CI validates. +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Dependency layer: every workspace member's manifest, resolved before the +# full source arrives. `uv.lock` is gitignored in this repo, so it's an +# optional glob and the sync deliberately isn't `--frozen`. +COPY pyproject.toml uv.lock* ./ +COPY framework/ framework/ +COPY modules/ modules/ +COPY host/pyproject.toml host/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --all-packages --no-dev --no-install-workspace + +# npm workspaces span host/client_app, packages/* and modules/* — every +# member's package.json must exist before `npm ci` will honour the lockfile. +COPY package.json package-lock.json ./ +COPY packages/ packages/ +COPY host/client_app/package.json host/client_app/ +RUN --mount=type=cache,target=/root/.npm npm ci + +COPY . . +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --all-packages --no-dev + +# Page manifest + generated module imports first, then the production bundle +# into host/static/dist (with its .vite/manifest.json and precompressed +# .gz/.br siblings, which the host serves from the /static mount). +# The venv binary directly rather than `uv run`, which re-resolves and re-syncs +# the environment on every invocation — the layer above already installed +# exactly what this image should contain. +RUN /app/.venv/bin/smpy host gen-pages --host-dir=host/client_app +RUN npm run build + +# node_modules is a build-time artifact only; the runtime serves static files. +RUN rm -rf node_modules host/client_app/node_modules + +FROM python:3.12-slim-bookworm AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/app/.venv/bin:$PATH" + +# Containers serve the built bundle; development mode would emit asset tags +# pointing at a Vite dev server that isn't in this image. +ENV SM_ENVIRONMENT=production + +# Absolute sqlite path: /app/data is the volume mount point, so the DB is +# cwd-independent and survives restarts whenever a volume is attached. +ENV SM_DATABASE_URL=sqlite+aiosqlite:////app/data/app.db + +# Celery refuses a localhost broker in production — that would mean the web +# container talking to its own (absent) 6379. `redis` is the compose service +# name; without that stack the app still boots and only task *dispatch* fails. +ENV SM_BG_TASKS_BROKER_URL=redis://redis:6379/0 \ + SM_BG_TASKS_RESULT_BACKEND=redis://redis:6379/1 + +# curl backs the HEALTHCHECK below. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app /app + +RUN mkdir -p /app/data \ + && useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin app \ + && chown -R app:app /app +USER app + +WORKDIR /app +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost:8000/health || exit 1 + +ENTRYPOINT ["/app/docker/entrypoint.sh"] +CMD ["uvicorn", "host.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 4293c4d2..66e3c5a7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.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 +.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 # Install install: @@ -184,6 +184,25 @@ kill: @-lsof -ti:8000,5050,5173 | xargs kill -9 2>/dev/null @echo "Ports 8000, 5050, 5173 freed." +# Docker — the default app image (./Dockerfile). Standalone: SQLite inside the +# container, no Postgres or Redis needed. Both run targets honour SM_APP_PORT +# so they don't collide with a `make dev` already holding 8000. +SM_APP_PORT ?= 8000 +export SM_APP_PORT + +docker-build: ## Build the default app image + docker build -t simple-module-python . + +docker-app: docker-build ## Run the built image standalone on http://localhost:$(SM_APP_PORT) + docker run --rm -p $(SM_APP_PORT):8000 \ + -e SM_USERS_BOOTSTRAP_EMAIL=admin@example.com \ + -e SM_USERS_BOOTSTRAP_PASSWORD=admin \ + -v simple-module-python-data:/app/data \ + simple-module-python + +docker-compose-app: ## Same image via compose (named volume, .env-overridable) + docker compose up --build app + # Docker — Postgres/Redis now live in the shared ../dev-services stack. # docker-up brings that shared stack up (idempotent, shared with other repos). docker-up: diff --git a/README.md b/README.md index 649c880a..4a9404b7 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,57 @@ make dev 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). +## Run it in Docker + +The repo ships a default image (`./Dockerfile`) that builds the host plus every +bundled module — Python workspace, `gen-pages`, and the production Vite bundle — +and serves it with uvicorn. It is standalone: SQLite lives inside the container +under `/app/data`, so no Postgres and no Redis are needed to boot. + +```bash +make docker-app # build ./Dockerfile, then run it on http://localhost:8000 +``` + +Both run targets take `SM_APP_PORT=8010` when a `make dev` already holds +8000. + +or without make: + +```bash +docker build -t simple-module-python . +docker run --rm -p 8000:8000 \ + -e SM_USERS_BOOTSTRAP_EMAIL=admin@example.com \ + -e SM_USERS_BOOTSTRAP_PASSWORD=admin \ + -v simple-module-python-data:/app/data \ + simple-module-python +``` + +`make docker-compose-app` (or `docker compose up --build app`) runs the same +image through compose with a named volume — independent of the shared +dev-services stack that `worker`/`beat` use. + +What the entrypoint does before uvicorn binds: applies `alembic upgrade heads` +(a fresh volume has no tables, and production fails boot on `SM010` when the DB +is behind head), and generates an ephemeral `SM_SECRET_KEY` when none is set — +enough to boot and log in, but sessions die on restart, so set one for anything +real. + +The image runs with `SM_ENVIRONMENT=production` because it serves the built +bundle rather than a Vite dev server. Useful overrides: + +| Variable | Image default | Why you'd change it | +|---|---|---| +| `SM_SECRET_KEY` | generated per start | Persist sessions across restarts | +| `SM_DATABASE_URL` | `sqlite+aiosqlite:////app/data/app.db` | Point at Postgres | +| `SM_BG_TASKS_BROKER_URL` / `_RESULT_BACKEND` | `redis://redis:6379/0` and `/1` | Reach a real Redis. Production refuses a localhost broker, so the image ships the compose hostname; with no Redis the app still serves, only task dispatch fails | +| `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` | unset | Seed the first admin while the users table is empty | +| `SM_TRUSTED_PROXY` | unset | Set to `*` behind a TLS-terminating reverse proxy | + +`docker-compose.yml`'s `worker` / `beat` services still build +`docker/worker.Dockerfile` and expect the shared `../dev-services` Postgres + +Redis on the external `devnet` network — that's the dev-stack path, not the +standalone one. + ## Create a new module ```bash @@ -105,6 +156,7 @@ docs/ | `make migration msg="..."` | Autogenerate a new migration | | `make new-module name=` | Scaffold a new module | | `make kill` | Stop any running dev servers (ports 8000, 5050, 5173) | +| `make docker-build` / `docker-app` | Build the default app image / run it standalone on port 8000 | | `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) | ## Configuration diff --git a/docker-compose.yml b/docker-compose.yml index 5a98a961..2b932c5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,27 @@ services: + # The default app — host + every bundled module — built from ./Dockerfile. + # Self-contained: SQLite on a named volume, no Postgres and no Redis + # required, so `docker compose up --build app` works with nothing else + # running. worker/beat below are the opt-in Celery half and still expect the + # shared ../dev-services stack. + app: + build: + context: . + dockerfile: Dockerfile + environment: + # Left unset the entrypoint generates an ephemeral key (fine for a demo, + # logs a warning, invalidates sessions on restart). + - SM_SECRET_KEY + # First-boot admin seed — only applied while the users table is empty. + # Override before exposing this to anything but localhost. + - SM_USERS_BOOTSTRAP_EMAIL=${SM_USERS_BOOTSTRAP_EMAIL:-admin@example.com} + - SM_USERS_BOOTSTRAP_PASSWORD=${SM_USERS_BOOTSTRAP_PASSWORD:-admin} + ports: + # SM_APP_PORT frees you from a host-port clash with `make dev`. + - "${SM_APP_PORT:-8000}:8000" + volumes: + - appdata:/app/data + # Postgres + Redis now come from the shared ../dev-services stack # (one PostGIS + one Redis on the external `devnet` network). Start it first # with `make up` in ~/Repos/dev-services. This project uses: @@ -9,7 +32,9 @@ services: build: context: . dockerfile: docker/worker.Dockerfile - env_file: .env + env_file: + - path: .env + required: false environment: SM_BG_TASKS_BROKER_URL: redis://redis:6379/4 SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/5 @@ -30,7 +55,9 @@ services: build: context: . dockerfile: docker/worker.Dockerfile - env_file: .env + env_file: + - path: .env + required: false environment: SM_BG_TASKS_BROKER_URL: redis://redis:6379/4 SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/5 @@ -52,3 +79,6 @@ services: networks: devnet: external: true + +volumes: + appdata: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..35fecfb3 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Container entrypoint for the default app image (see ../Dockerfile). +# +# Two things have to happen before uvicorn binds, both of which the image can +# do for itself so `docker run -p 8000:8000 ` is enough: +# +# 1. Fill in the secrets production refuses to run without. The image ships +# no baked-in keys (an image everyone can pull is the worst possible +# place for one), so any that are still unset get an ephemeral random +# value — enough to boot and log in, gone on the next start. +# 2. Apply migrations. A fresh SQLite volume has no tables at all, and the +# boot-time SM010 check fails the app in production when the DB revision +# is behind head. +set -e + +# SM_SECRET_KEY signs session cookies; the two SM_USERS_* secrets sign +# password-reset and email-verification tokens. All three reject their +# placeholder default when SM_ENVIRONMENT is a production value. +_ephemeral="" +for _var in SM_SECRET_KEY SM_USERS_RESET_PASSWORD_TOKEN_SECRET SM_USERS_VERIFICATION_TOKEN_SECRET; do + eval "_current=\${$_var:-}" + if [ -z "$_current" ]; then + eval "export $_var=\"\$(python -c 'import secrets; print(secrets.token_urlsafe(48))')\"" + _ephemeral="$_ephemeral $_var" + fi +done + +if [ -n "$_ephemeral" ]; then + echo "entrypoint: generated ephemeral secrets for:$_ephemeral" >&2 + echo "entrypoint: sessions and any reset/verification links they sign die on restart — set these to persist them." >&2 +fi + +# `upgrade heads` (plural) applies every per-module migration branch; +# `upgrade head` (singular) errors once a second module ships a branch label. +echo "entrypoint: applying migrations..." >&2 +alembic -c host/alembic.ini upgrade heads + +exec "$@" diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 1f950329..d1839d0a 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -19,6 +19,32 @@ Before serving traffic: ## Build +### This repo's own app + +The framework repo ships a default image at the root — `./Dockerfile` — that +builds the reference app (host + every bundled module) and serves it with +uvicorn. It is standalone: SQLite under `/app/data`, no Postgres and no Redis +needed to boot. + +```bash +make docker-app # build ./Dockerfile and run it on :8000 +make docker-compose-app # same image via the `app` service in docker-compose.yml +``` + +`docker/entrypoint.sh` runs `alembic upgrade heads` and fills in any unset +production secret (`SM_SECRET_KEY`, `SM_USERS_RESET_PASSWORD_TOKEN_SECRET`, +`SM_USERS_VERIFICATION_TOKEN_SECRET`) with an ephemeral random value so a bare +`docker run` boots — set them yourself for anything that must survive a +restart. The checklist above still applies: that image is a demo/local target, +not a production deployment (SQLite, ephemeral secrets). + +Because production refuses a localhost Celery broker, the image ships +`SM_BG_TASKS_BROKER_URL=redis://redis:6379/0` (and `/1` for the result +backend) — the compose hostnames. With no Redis reachable the app still serves +every page; only task dispatch fails. + +### Scaffolded apps + **Every `smpy new` scaffold ships its own Docker assets** — you don't write these by hand: | Path | What it is | diff --git a/modules/background_tasks/background_tasks/settings.py b/modules/background_tasks/background_tasks/settings.py index 63d84a6d..ef3d56f0 100644 --- a/modules/background_tasks/background_tasks/settings.py +++ b/modules/background_tasks/background_tasks/settings.py @@ -1,11 +1,19 @@ """BackgroundTasks module settings (DB-backed). -Construction no longer reads ``SM_BG_TASKS_*`` environment variables. Values -come from pydantic defaults at boot, then get hydrated from the DB by the +Values come from defaults at boot, then get hydrated from the DB by the hosting lifespan before module ``on_startup`` runs. Runtime changes go through ``settings.reload.apply_changes_and_reload``. -The one remaining env read is ``SM_ENVIRONMENT``, consulted by the +Two of those defaults are deployment plumbing rather than module config, so +they stay env-readable: ``SM_BG_TASKS_BROKER_URL`` and +``SM_BG_TASKS_RESULT_BACKEND`` name the Redis a container can actually reach. +They have to work *before* any DB row exists — the production validator below +rejects the localhost defaults, so without them a containerised app can't +boot far enough to hydrate settings, and a worker process (which never sees +``app.state``) has no other source at all. A DB value still wins once +hydration runs. + +The other env read is ``SM_ENVIRONMENT``, consulted by the ``@model_validator`` to refuse a localhost broker in production — that's a host-level setting, not a background_tasks-module field. @@ -34,6 +42,7 @@ DEFAULT_RETENTION_DAYS, DEFAULT_STUCK_AFTER_SECONDS, DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS, + ENV_PREFIX, ) _CELERY_RESTART = {"requires_restart": True, "group": "Celery"} @@ -44,8 +53,16 @@ class BackgroundTasksSettings(BaseSettings): model_config = SettingsConfigDict(extra="ignore") - broker_url: str = Field(default=DEFAULT_BROKER_URL, json_schema_extra=_CELERY_RESTART) - result_backend: str = Field(default=DEFAULT_RESULT_BACKEND, json_schema_extra=_CELERY_RESTART) + broker_url: str = Field( + default_factory=lambda: os.environ.get(f"{ENV_PREFIX}BROKER_URL", DEFAULT_BROKER_URL), + json_schema_extra=_CELERY_RESTART, + ) + result_backend: str = Field( + default_factory=lambda: os.environ.get( + f"{ENV_PREFIX}RESULT_BACKEND", DEFAULT_RESULT_BACKEND + ), + json_schema_extra=_CELERY_RESTART, + ) task_default_queue: str = Field(default=DEFAULT_QUEUE, json_schema_extra=_CELERY_RESTART) # Run tasks synchronously inside the calling process. Read at diff --git a/modules/background_tasks/tests/test_bg_settings_env.py b/modules/background_tasks/tests/test_bg_settings_env.py new file mode 100644 index 00000000..1337f063 --- /dev/null +++ b/modules/background_tasks/tests/test_bg_settings_env.py @@ -0,0 +1,58 @@ +"""Broker/result-backend env plumbing for BackgroundTasksSettings. + +These two fields are read from the environment at construction because they +have to be right *before* the DB-backed settings exist: a container boots in +production, where the localhost defaults are rejected, and a Celery worker +process never sees ``app.state`` at all. Everything else is DB-backed. +""" + +from __future__ import annotations + +import pytest +from background_tasks.constants import DEFAULT_BROKER_URL, DEFAULT_RESULT_BACKEND +from background_tasks.settings import BackgroundTasksSettings + + +def test_defaults_when_env_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SM_BG_TASKS_BROKER_URL", raising=False) + monkeypatch.delenv("SM_BG_TASKS_RESULT_BACKEND", raising=False) + + settings = BackgroundTasksSettings() + + assert settings.broker_url == DEFAULT_BROKER_URL + assert settings.result_backend == DEFAULT_RESULT_BACKEND + + +def test_env_overrides_broker_urls(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SM_BG_TASKS_BROKER_URL", "redis://redis:6379/4") + monkeypatch.setenv("SM_BG_TASKS_RESULT_BACKEND", "redis://redis:6379/5") + + settings = BackgroundTasksSettings() + + assert settings.broker_url == "redis://redis:6379/4" + assert settings.result_backend == "redis://redis:6379/5" + + +def test_env_urls_satisfy_the_production_validator(monkeypatch: pytest.MonkeyPatch) -> None: + """The container path: production + a reachable broker must construct. + + Without env-backed defaults this raised, so no production container could + boot with the module installed — the localhost defaults were the only + values the validator ever saw. + """ + monkeypatch.setenv("SM_ENVIRONMENT", "production") + monkeypatch.setenv("SM_BG_TASKS_BROKER_URL", "redis://redis:6379/0") + monkeypatch.setenv("SM_BG_TASKS_RESULT_BACKEND", "redis://redis:6379/1") + + settings = BackgroundTasksSettings() + + assert settings.broker_url == "redis://redis:6379/0" + + +def test_localhost_still_rejected_in_production(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SM_ENVIRONMENT", "production") + monkeypatch.delenv("SM_BG_TASKS_BROKER_URL", raising=False) + monkeypatch.delenv("SM_BG_TASKS_RESULT_BACKEND", raising=False) + + with pytest.raises(ValueError, match="must not point at localhost"): + BackgroundTasksSettings() From 96899f09f31877a4dfbf053e2741bf64a24b5e54 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 22 Aug 2026 11:43:26 +0200 Subject: [PATCH 2/4] fix(docker): leave Celery out of the default app image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Dockerfile | 22 ++++++++++++---------- README.md | 13 ++++++++----- docker-compose.yml | 10 +++++----- docs/reference/deployment.md | 11 +++++++---- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4ab9e31e..3253b14e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,11 @@ # syntax=docker/dockerfile:1.7 -# Default image for the SimpleModule reference app — the host plus every -# bundled module (auth/users, dashboard, permissions, settings, background -# tasks, file storage, feature flags, audit log, branding, site lock). +# Default image for the SimpleModule reference app — the host plus the bundled +# modules that a single web process needs (auth/users, dashboard, permissions, +# settings, file storage, feature flags, audit log, branding, site lock). +# +# Background tasks are deliberately not part of it: Celery is a second process +# plus a broker, which is the opposite of a standalone image. The worker/beat +# services in docker-compose.yml build docker/worker.Dockerfile for that. # # docker build -t simple-module-python . # docker run --rm -p 8000:8000 simple-module-python @@ -45,9 +49,13 @@ COPY packages/ packages/ COPY host/client_app/package.json host/client_app/ RUN --mount=type=cache,target=/root/.npm npm ci +# --no-install-package drops the Celery module from this image: with no entry +# point installed, discovery never sees it, so nothing here needs a broker and +# the bundle carries none of its pages. Drop the flag (and point +# SM_BG_TASKS_BROKER_URL at a real Redis) to run tasks from the web process. COPY . . RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --all-packages --no-dev + uv sync --all-packages --no-dev --no-install-package simple-module-background-tasks # Page manifest + generated module imports first, then the production bundle # into host/static/dist (with its .vite/manifest.json and precompressed @@ -75,12 +83,6 @@ ENV SM_ENVIRONMENT=production # cwd-independent and survives restarts whenever a volume is attached. ENV SM_DATABASE_URL=sqlite+aiosqlite:////app/data/app.db -# Celery refuses a localhost broker in production — that would mean the web -# container talking to its own (absent) 6379. `redis` is the compose service -# name; without that stack the app still boots and only task *dispatch* fails. -ENV SM_BG_TASKS_BROKER_URL=redis://redis:6379/0 \ - SM_BG_TASKS_RESULT_BACKEND=redis://redis:6379/1 - # curl backs the HEALTHCHECK below. RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ diff --git a/README.md b/README.md index 4a9404b7..29a4f186 100644 --- a/README.md +++ b/README.md @@ -89,14 +89,17 @@ bundle rather than a Vite dev server. Useful overrides: |---|---|---| | `SM_SECRET_KEY` | generated per start | Persist sessions across restarts | | `SM_DATABASE_URL` | `sqlite+aiosqlite:////app/data/app.db` | Point at Postgres | -| `SM_BG_TASKS_BROKER_URL` / `_RESULT_BACKEND` | `redis://redis:6379/0` and `/1` | Reach a real Redis. Production refuses a localhost broker, so the image ships the compose hostname; with no Redis the app still serves, only task dispatch fails | | `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` | unset | Seed the first admin while the users table is empty | | `SM_TRUSTED_PROXY` | unset | Set to `*` behind a TLS-terminating reverse proxy | -`docker-compose.yml`'s `worker` / `beat` services still build -`docker/worker.Dockerfile` and expect the shared `../dev-services` Postgres + -Redis on the external `devnet` network — that's the dev-stack path, not the -standalone one. +**No background tasks.** The image skips installing the Celery module +(`uv sync … --no-install-package simple-module-background-tasks`), so nothing in +it wants a broker — a queue means a second process and a Redis, which is the +opposite of a standalone image. `docker-compose.yml`'s `worker` / `beat` +services cover that path: they build `docker/worker.Dockerfile` against the +shared `../dev-services` Postgres + Redis on the external `devnet` network. To +run tasks from the web process instead, drop the `--no-install-package` flag +from the Dockerfile and set `SM_BG_TASKS_BROKER_URL` / `_RESULT_BACKEND`. ## Create a new module diff --git a/docker-compose.yml b/docker-compose.yml index 2b932c5e..6f7b0ab9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,9 @@ services: - # The default app — host + every bundled module — built from ./Dockerfile. - # Self-contained: SQLite on a named volume, no Postgres and no Redis - # required, so `docker compose up --build app` works with nothing else - # running. worker/beat below are the opt-in Celery half and still expect the - # shared ../dev-services stack. + # The default app, built from ./Dockerfile. Self-contained: SQLite on a + # named volume, no Postgres and no Redis required, so + # `docker compose up --build app` works with nothing else running. The image + # ships no Celery module — worker/beat below are the background-jobs half and + # still expect the shared ../dev-services stack. app: build: context: . diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index d1839d0a..07413386 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -38,10 +38,13 @@ production secret (`SM_SECRET_KEY`, `SM_USERS_RESET_PASSWORD_TOKEN_SECRET`, restart. The checklist above still applies: that image is a demo/local target, not a production deployment (SQLite, ephemeral secrets). -Because production refuses a localhost Celery broker, the image ships -`SM_BG_TASKS_BROKER_URL=redis://redis:6379/0` (and `/1` for the result -backend) — the compose hostnames. With no Redis reachable the app still serves -every page; only task dispatch fails. +That image carries no Celery: the build passes +`--no-install-package simple-module-background-tasks`, so the module has no +entry point to discover and the web process needs no broker at all. Background +jobs are the `worker` / `beat` services in `docker-compose.yml` +(`docker/worker.Dockerfile`), which is where a second process and a Redis +belong. Drop that flag and set `SM_BG_TASKS_BROKER_URL` / `_RESULT_BACKEND` to +put tasks back in the web image. ### Scaffolded apps From c278ff2311cf50fc005ee4caa26c4d2ec3f42db9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 22 Aug 2026 15:35:31 +0200 Subject: [PATCH 3/4] 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 --- Makefile | 2 -- README.md | 21 +++++++++++++++------ docker-compose.yml | 9 +++++---- docker/entrypoint.sh | 23 +++++++++++++++++++++-- docs/reference/deployment.md | 7 +++++-- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 66e3c5a7..40d0bd93 100644 --- a/Makefile +++ b/Makefile @@ -195,8 +195,6 @@ docker-build: ## Build the default app image docker-app: docker-build ## Run the built image standalone on http://localhost:$(SM_APP_PORT) docker run --rm -p $(SM_APP_PORT):8000 \ - -e SM_USERS_BOOTSTRAP_EMAIL=admin@example.com \ - -e SM_USERS_BOOTSTRAP_PASSWORD=admin \ -v simple-module-python-data:/app/data \ simple-module-python diff --git a/README.md b/README.md index 29a4f186..2f384e43 100644 --- a/README.md +++ b/README.md @@ -65,13 +65,22 @@ or without make: ```bash docker build -t simple-module-python . -docker run --rm -p 8000:8000 \ - -e SM_USERS_BOOTSTRAP_EMAIL=admin@example.com \ - -e SM_USERS_BOOTSTRAP_PASSWORD=admin \ - -v simple-module-python-data:/app/data \ - simple-module-python +docker run --rm -p 8000:8000 -v simple-module-python-data:/app/data simple-module-python ``` +**Logging in.** With no `SM_USERS_BOOTSTRAP_*` set, the entrypoint seeds +`admin@example.com` with a generated password and prints it once: + +``` +entrypoint: no SM_USERS_BOOTSTRAP_PASSWORD set — first-boot admin is +entrypoint: admin@example.com / kQ7v-2XbnMr9 +``` + +Pass `-e SM_USERS_BOOTSTRAP_EMAIL=… -e SM_USERS_BOOTSTRAP_PASSWORD=…` to choose +your own. The seed only applies while the users table is empty, so on a reused +volume the original password still stands — reset it with +`smpy users create-admin --email … --password … --force`. + `make docker-compose-app` (or `docker compose up --build app`) runs the same image through compose with a named volume — independent of the shared dev-services stack that `worker`/`beat` use. @@ -89,7 +98,7 @@ bundle rather than a Vite dev server. Useful overrides: |---|---|---| | `SM_SECRET_KEY` | generated per start | Persist sessions across restarts | | `SM_DATABASE_URL` | `sqlite+aiosqlite:////app/data/app.db` | Point at Postgres | -| `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` | unset | Seed the first admin while the users table is empty | +| `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` | `admin@example.com` / generated | Choose the first admin's credentials instead of reading them from the log | | `SM_TRUSTED_PROXY` | unset | Set to `*` behind a TLS-terminating reverse proxy | **No background tasks.** The image skips installing the Celery module diff --git a/docker-compose.yml b/docker-compose.yml index 6f7b0ab9..8b0af536 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,10 +12,11 @@ services: # Left unset the entrypoint generates an ephemeral key (fine for a demo, # logs a warning, invalidates sessions on restart). - SM_SECRET_KEY - # First-boot admin seed — only applied while the users table is empty. - # Override before exposing this to anything but localhost. - - SM_USERS_BOOTSTRAP_EMAIL=${SM_USERS_BOOTSTRAP_EMAIL:-admin@example.com} - - SM_USERS_BOOTSTRAP_PASSWORD=${SM_USERS_BOOTSTRAP_PASSWORD:-admin} + # First-boot admin seed, applied only while the users table is empty. + # Unset, the entrypoint seeds admin@example.com with a generated + # password and prints it — see `docker compose logs app`. + - SM_USERS_BOOTSTRAP_EMAIL + - SM_USERS_BOOTSTRAP_PASSWORD ports: # SM_APP_PORT frees you from a host-port clash with `make dev`. - "${SM_APP_PORT:-8000}:8000" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 35fecfb3..4cd80424 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,14 +1,15 @@ #!/bin/sh # Container entrypoint for the default app image (see ../Dockerfile). # -# Two things have to happen before uvicorn binds, both of which the image can +# Three things have to happen before uvicorn binds, all of which the image can # do for itself so `docker run -p 8000:8000 ` is enough: # # 1. Fill in the secrets production refuses to run without. The image ships # no baked-in keys (an image everyone can pull is the worst possible # place for one), so any that are still unset get an ephemeral random # value — enough to boot and log in, gone on the next start. -# 2. Apply migrations. A fresh SQLite volume has no tables at all, and the +# 2. Seed an admin, so a bare `docker run` lands on a login you can pass. +# 3. Apply migrations. A fresh SQLite volume has no tables at all, and the # boot-time SM010 check fails the app in production when the DB revision # is behind head. set -e @@ -30,6 +31,24 @@ if [ -n "$_ephemeral" ]; then echo "entrypoint: sessions and any reset/verification links they sign die on restart — set these to persist them." >&2 fi +# The seed the users module applies only while its table is empty, so this +# can't overwrite an account on a persistent volume — and without it a fresh +# container serves a login page nobody holds credentials for. The password is +# generated rather than a baked-in `admin`: an image everyone can pull is the +# worst place for a known password, and the same image runs on real hosts. +: "${SM_USERS_BOOTSTRAP_EMAIL:=admin@example.com}" +export SM_USERS_BOOTSTRAP_EMAIL + +if [ -z "${SM_USERS_BOOTSTRAP_PASSWORD:-}" ]; then + SM_USERS_BOOTSTRAP_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(12))')" + export SM_USERS_BOOTSTRAP_PASSWORD + echo "entrypoint: no SM_USERS_BOOTSTRAP_PASSWORD set — first-boot admin is" >&2 + echo "entrypoint: $SM_USERS_BOOTSTRAP_EMAIL / $SM_USERS_BOOTSTRAP_PASSWORD" >&2 + echo "entrypoint: printed once, and only used if no user exists yet. On a reused" >&2 + echo "entrypoint: volume the original password still stands; reset it with" >&2 + echo "entrypoint: \`smpy users create-admin --email ... --password ... --force\`." >&2 +fi + # `upgrade heads` (plural) applies every per-module migration branch; # `upgrade head` (singular) errors once a second module ships a branch label. echo "entrypoint: applying migrations..." >&2 diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 07413386..0cfb8e45 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -35,8 +35,11 @@ make docker-compose-app # same image via the `app` service in docker-compose production secret (`SM_SECRET_KEY`, `SM_USERS_RESET_PASSWORD_TOKEN_SECRET`, `SM_USERS_VERIFICATION_TOKEN_SECRET`) with an ephemeral random value so a bare `docker run` boots — set them yourself for anything that must survive a -restart. The checklist above still applies: that image is a demo/local target, -not a production deployment (SQLite, ephemeral secrets). +restart. It also seeds `admin@example.com` with a generated password printed to +the log, unless `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` say otherwise; that +seed only lands while the users table is empty, so it never overwrites an +existing account. The checklist above still applies: that image is a demo/local +target, not a production deployment (SQLite, ephemeral secrets). That image carries no Celery: the build passes `--no-install-package simple-module-background-tasks`, so the module has no From 75180d336813a3d3c7c3024e26743c9cebda855f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 23 Aug 2026 21:53:20 +0200 Subject: [PATCH 4/4] 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 --- README.md | 19 +++++++++++-------- docker/entrypoint.sh | 29 ++++++++++++++++------------- docs/reference/deployment.md | 9 +++++---- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 2f384e43..de5a904d 100644 --- a/README.md +++ b/README.md @@ -68,17 +68,20 @@ docker build -t simple-module-python . docker run --rm -p 8000:8000 -v simple-module-python-data:/app/data simple-module-python ``` -**Logging in.** With no `SM_USERS_BOOTSTRAP_*` set, the entrypoint seeds -`admin@example.com` with a generated password and prints it once: +**Logging in.** With no `SM_USERS_BOOTSTRAP_*` set, the container seeds +**`admin@example.com` / `changeme`** — the same pair `.env.example` uses for +local dev — and says so on every boot that uses it: ``` -entrypoint: no SM_USERS_BOOTSTRAP_PASSWORD set — first-boot admin is -entrypoint: admin@example.com / kQ7v-2XbnMr9 +entrypoint: WARNING - no SM_USERS_BOOTSTRAP_PASSWORD set, so the +entrypoint: first-boot admin is the public default: +entrypoint: admin@example.com / changeme ``` -Pass `-e SM_USERS_BOOTSTRAP_EMAIL=… -e SM_USERS_BOOTSTRAP_PASSWORD=…` to choose -your own. The seed only applies while the users table is empty, so on a reused -volume the original password still stands — reset it with +Pass `-e SM_USERS_BOOTSTRAP_EMAIL=… -e SM_USERS_BOOTSTRAP_PASSWORD=…` to seed +your own instead, and change it before the container is reachable by anyone but +you. The seed only applies while the users table is empty, so on a reused volume +the existing password still stands — reset it with `smpy users create-admin --email … --password … --force`. `make docker-compose-app` (or `docker compose up --build app`) runs the same @@ -98,7 +101,7 @@ bundle rather than a Vite dev server. Useful overrides: |---|---|---| | `SM_SECRET_KEY` | generated per start | Persist sessions across restarts | | `SM_DATABASE_URL` | `sqlite+aiosqlite:////app/data/app.db` | Point at Postgres | -| `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` | `admin@example.com` / generated | Choose the first admin's credentials instead of reading them from the log | +| `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` | `admin@example.com` / `changeme` | Seed a first admin nobody else can guess | | `SM_TRUSTED_PROXY` | unset | Set to `*` behind a TLS-terminating reverse proxy | **No background tasks.** The image skips installing the Celery module diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 4cd80424..4a3c55ee 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -8,7 +8,8 @@ # no baked-in keys (an image everyone can pull is the worst possible # place for one), so any that are still unset get an ephemeral random # value — enough to boot and log in, gone on the next start. -# 2. Seed an admin, so a bare `docker run` lands on a login you can pass. +# 2. Seed an admin, so a bare `docker run` lands on a login you can pass +# (admin@example.com / changeme unless SM_USERS_BOOTSTRAP_* say otherwise). # 3. Apply migrations. A fresh SQLite volume has no tables at all, and the # boot-time SM010 check fails the app in production when the DB revision # is behind head. @@ -33,20 +34,22 @@ fi # The seed the users module applies only while its table is empty, so this # can't overwrite an account on a persistent volume — and without it a fresh -# container serves a login page nobody holds credentials for. The password is -# generated rather than a baked-in `admin`: an image everyone can pull is the -# worst place for a known password, and the same image runs on real hosts. +# container serves a login page nobody holds credentials for. The default is +# deliberately a well-known one: this image is a starting point you are meant +# to log straight into. Anything running where that matters should pass both +# vars, which is why the banner below is loud rather than silent. : "${SM_USERS_BOOTSTRAP_EMAIL:=admin@example.com}" -export SM_USERS_BOOTSTRAP_EMAIL +: "${SM_USERS_BOOTSTRAP_PASSWORD:=changeme}" +export SM_USERS_BOOTSTRAP_EMAIL SM_USERS_BOOTSTRAP_PASSWORD -if [ -z "${SM_USERS_BOOTSTRAP_PASSWORD:-}" ]; then - SM_USERS_BOOTSTRAP_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(12))')" - export SM_USERS_BOOTSTRAP_PASSWORD - echo "entrypoint: no SM_USERS_BOOTSTRAP_PASSWORD set — first-boot admin is" >&2 - echo "entrypoint: $SM_USERS_BOOTSTRAP_EMAIL / $SM_USERS_BOOTSTRAP_PASSWORD" >&2 - echo "entrypoint: printed once, and only used if no user exists yet. On a reused" >&2 - echo "entrypoint: volume the original password still stands; reset it with" >&2 - echo "entrypoint: \`smpy users create-admin --email ... --password ... --force\`." >&2 +if [ "$SM_USERS_BOOTSTRAP_PASSWORD" = "changeme" ]; then + echo "entrypoint: WARNING - no SM_USERS_BOOTSTRAP_PASSWORD set, so the" >&2 + echo "entrypoint: first-boot admin is the public default:" >&2 + echo "entrypoint: $SM_USERS_BOOTSTRAP_EMAIL / changeme" >&2 + echo "entrypoint: Seeded only while no user exists — an existing install is" >&2 + echo "entrypoint: left alone. Set SM_USERS_BOOTSTRAP_EMAIL/_PASSWORD before" >&2 + echo "entrypoint: first boot, or change it later with:" >&2 + echo "entrypoint: smpy users create-admin --email ... --password ... --force" >&2 fi # `upgrade heads` (plural) applies every per-module migration branch; diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 0cfb8e45..10671967 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -35,10 +35,11 @@ make docker-compose-app # same image via the `app` service in docker-compose production secret (`SM_SECRET_KEY`, `SM_USERS_RESET_PASSWORD_TOKEN_SECRET`, `SM_USERS_VERIFICATION_TOKEN_SECRET`) with an ephemeral random value so a bare `docker run` boots — set them yourself for anything that must survive a -restart. It also seeds `admin@example.com` with a generated password printed to -the log, unless `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` say otherwise; that -seed only lands while the users table is empty, so it never overwrites an -existing account. The checklist above still applies: that image is a demo/local +restart. It also seeds `admin@example.com` / `changeme` (warning printed on +every boot that uses it) unless `SM_USERS_BOOTSTRAP_EMAIL` / `_PASSWORD` say +otherwise; that seed only lands while the users table is empty, so it never +overwrites an existing account. A deployment that anyone else can reach should +set both vars before first boot — the default password is public. The checklist above still applies: that image is a demo/local target, not a production deployment (SQLite, ephemeral secrets). That image carries no Celery: the build passes