feat: image-based docker self-hosting - #225
Conversation
Three gaps between the everything-optional policy and reality: - Unknown Host headers got a sitewide 404 even with custom domains disabled — any deploy behind a domain/IP the app wasn't told about was dead, including /health. Middleware now passes unknown hosts through to the full app when the feature is off (the SaaS 404 stays when on). - Empty JWT config crashed register/login with InvalidKeyError (pyjwt refuses empty HMAC keys). Boot now autogenerates an ephemeral secret with a loud warning instead. - .env.example shipped non-empty JWT placeholder keys, forcing RS256 with garbage — auth crashed on first login for anyone who kept them.
selfhost/docker-compose.yml runs the published GHCR image with bundled
MongoDB and Redis — zero-edit boot, no clone, no build, no .env
required. Every setting is a ${VAR:-default} so a .env next to the file
or shell env overrides without editing. Version pinning via
SPOO_VERSION (Immich-style). The deploy workflow uploads the file to
each release, so releases/latest/download/docker-compose.yml always
matches the newest image.
📝 WalkthroughWalkthroughThe change adds self-hosting Compose deployment assets, multi-architecture image publishing, a multi-stage non-root container build, automatic HS256 secret bootstrap, and configurable tenant pass-through behavior. ChangesSelf-hosted runtime
Sequence Diagram(s)sequenceDiagram
participant ReleaseWorkflow
participant ContainerRegistry
participant DockerCompose
participant app
participant mongo
participant redis
ReleaseWorkflow->>ContainerRegistry: build and publish AMD64/ARM64 image
ReleaseWorkflow->>DockerCompose: upload docker-compose.yml release asset
DockerCompose->>mongo: start and check health
DockerCompose->>redis: start and check health
DockerCompose->>app: start after dependencies are healthy
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Reviewer's GuideIntroduces a zero-edit Docker Compose self-hosting flow backed by a published image, while fixing JWT bootstrap issues and relaxing tenant middleware behavior for non-SaaS deployments. Sequence diagram for TenantMiddleware behavior with custom domains disabledsequenceDiagram
actor Client
participant FastAPI
participant TenantMiddleware
participant AppRoute
Client->>FastAPI: HTTP request (Host header)
FastAPI->>TenantMiddleware: dispatch(request, call_next)
TenantMiddleware->>TenantMiddleware: [tenant lookup]
alt tenant found
TenantMiddleware->>FastAPI: call_next(request)
FastAPI->>AppRoute: route handler
AppRoute-->>Client: response
else tenant is None and custom_domains_enabled = false
TenantMiddleware->>FastAPI: call_next(request)
FastAPI->>AppRoute: route handler
AppRoute-->>Client: full app response (e.g. /health 200)
else tenant is None and custom_domains_enabled = true
TenantMiddleware-->>Client: _tenant_not_found(request, tenant=None) (404)
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 2 issues
Fixed security issues:
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path=".github/workflows/deploy.yml" line_range="42-45" />
<code_context>
+ with:
+ persist-credentials: false
+
+ - name: Upload self-host compose to release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release upload ${{ github.event.release.tag_name }} selfhost/docker-compose.yml --clobber
+
build:
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The workflow assumes `gh` is available on the runner, which might break if GitHub changes the default image.
The `assets` job calls `gh release upload` and assumes the GitHub CLI is preinstalled on `ubuntu-latest`. To avoid brittle, image-dependent failures, consider adding an explicit install/setup step for `gh` or pinning the runner image to one that guarantees `gh` is available.
</issue_to_address>
### Comment 2
<location path="tests/unit/test_jwt_secret_bootstrap.py" line_range="41-44" />
<code_context>
+ _ensure_jwt_secret(settings)
+ assert settings.jwt.jwt_secret == secret
+
+ def test_short_secret_kept_but_not_replaced(self):
+ settings = _settings(jwt_secret="short")
+ _ensure_jwt_secret(settings)
+ assert settings.jwt.jwt_secret == "short"
+
+ def test_rs256_config_is_untouched(self):
</code_context>
<issue_to_address>
**suggestion (testing):** Also assert that a warning is emitted for short JWT secrets to lock in the security feedback behavior
This test verifies the secret is preserved, but `_ensure_jwt_secret` should also emit a `jwt_secret_weak` warning in this case. Please add an assertion on the logged warning so that any future change that removes or weakens this security signal is caught. You can reuse the logging capture pattern from the autogenerated-secret test.
Suggested implementation:
```python
def test_short_secret_kept_but_not_replaced(self, caplog):
settings = _settings(jwt_secret="short")
with caplog.at_level(logging.WARNING):
_ensure_jwt_secret(settings)
assert settings.jwt.jwt_secret == "short"
# Ensure the weak-secret warning is emitted so security feedback stays intact.
# Pattern should match the autogenerated-secret test's logging assertion.
assert any(
record.levelname == "WARNING"
and record.message == "jwt_secret_weak"
for record in caplog.records
)
```
1. Ensure `logging` is imported at the top of this test module (e.g. `import logging`) if it is not already.
2. Align the warning assertion (message text and possibly logger name) with whatever pattern is used in the existing autogenerated-secret test (e.g. checking `record.message`, `record.getMessage()`, or `record.msg`, and any specific logger name or extra fields that test may rely on).
3. If the autogenerated-secret test uses a helper or a different structure for capturing log records (such as filtering by `record.name` or checking a specific substring in the message), mirror that structure here for consistency.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| - name: Upload self-host compose to release | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: gh release upload ${{ github.event.release.tag_name }} selfhost/docker-compose.yml --clobber |
There was a problem hiding this comment.
suggestion (bug_risk): The workflow assumes gh is available on the runner, which might break if GitHub changes the default image.
The assets job calls gh release upload and assumes the GitHub CLI is preinstalled on ubuntu-latest. To avoid brittle, image-dependent failures, consider adding an explicit install/setup step for gh or pinning the runner image to one that guarantees gh is available.
| def test_short_secret_kept_but_not_replaced(self): | ||
| settings = _settings(jwt_secret="short") | ||
| _ensure_jwt_secret(settings) | ||
| assert settings.jwt.jwt_secret == "short" |
There was a problem hiding this comment.
suggestion (testing): Also assert that a warning is emitted for short JWT secrets to lock in the security feedback behavior
This test verifies the secret is preserved, but _ensure_jwt_secret should also emit a jwt_secret_weak warning in this case. Please add an assertion on the logged warning so that any future change that removes or weakens this security signal is caught. You can reuse the logging capture pattern from the autogenerated-secret test.
Suggested implementation:
def test_short_secret_kept_but_not_replaced(self, caplog):
settings = _settings(jwt_secret="short")
with caplog.at_level(logging.WARNING):
_ensure_jwt_secret(settings)
assert settings.jwt.jwt_secret == "short"
# Ensure the weak-secret warning is emitted so security feedback stays intact.
# Pattern should match the autogenerated-secret test's logging assertion.
assert any(
record.levelname == "WARNING"
and record.message == "jwt_secret_weak"
for record in caplog.records
)- Ensure
loggingis imported at the top of this test module (e.g.import logging) if it is not already. - Align the warning assertion (message text and possibly logger name) with whatever pattern is used in the existing autogenerated-secret test (e.g. checking
record.message,record.getMessage(), orrecord.msg, and any specific logger name or extra fields that test may rely on). - If the autogenerated-secret test uses a helper or a different structure for capturing log records (such as filtering by
record.nameor checking a specific substring in the message), mirror that structure here for consistency.
Release images now build for linux/arm64 alongside amd64 — Apple Silicon, Raspberry Pi, and ARM cloud boxes couldn't pull at all (no matching manifest). Per-merge edge builds stay amd64-only for speed; prod pulls the amd64 leg of the manifest unchanged.
Builder stage installs the locked dependencies keyed on pyproject+lock alone, so code edits never invalidate the install layer. Runtime image drops the dev group (pytest/ruff/pre-commit — 92MB lighter) and runs as appuser. UV_NO_SYNC pins uv run entrypoints to the baked venv so container starts never try to re-sync the missing dev group.
4b6b4d8 to
4b9b928
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
dockerfile (1)
4-4: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPin the
uvimage version instead of using:latest.Both the builder and runtime stages copy from
ghcr.io/astral-sh/uv:latest. Floating:latestmakes builds non-reproducible and vulnerable to breaking changes if astral-sh releases an incompatible uv version. Pin to a specific version (e.g.,ghcr.io/astral-sh/uv:0.5.x) for reproducible builds.♻️ Suggested change
-COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /bin/Also applies to: 32-32
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@dockerfile` at line 4, Replace the floating :latest tag in both uv image references used by the builder and runtime stages with the approved specific uv version, keeping the existing /uv, /uvx, and /bin/ copy paths unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/deploy.yml:
- Around line 42-45: Update the “Upload self-host compose to release” step to
pass the release tag through its env configuration using a descriptive variable,
then reference that shell environment variable in the gh release upload command
instead of interpolating github.event.release.tag_name directly in run.
---
Nitpick comments:
In `@dockerfile`:
- Line 4: Replace the floating :latest tag in both uv image references used by
the builder and runtime stages with the approved specific uv version, keeping
the existing /uv, /uvx, and /bin/ copy paths unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4a7ea566-6f98-4eb7-9583-2ee0d5837da1
📒 Files selected for processing (8)
.env.example.github/workflows/deploy.ymlapp.pydockerfilemiddleware/tenant.pyselfhost/docker-compose.ymltests/unit/middleware/test_tenant.pytests/unit/test_jwt_secret_bootstrap.py
| - name: Upload self-host compose to release | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: gh release upload ${{ github.event.release.tag_name }} selfhost/docker-compose.yml --clobber |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Pass tag_name through an environment variable to prevent template injection.
github.event.release.tag_name is interpolated directly into a run command via ${{ }}. While Git tag names are character-restricted (no spaces, semicolons, or backticks), this is a confirmed template-injection finding from zizmor and violates the GitHub Actions security best practice of never expanding ${{ }} expressions inside run steps. The fix is trivial — move the value into an environment variable and reference it by name.
🔒️ Proposed fix: use env var instead of direct interpolation
- name: Upload self-host compose to release
env:
GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
- run: gh release upload ${{ github.event.release.tag_name }} selfhost/docker-compose.yml --clobber
+ run: gh release upload "$RELEASE_TAG" selfhost/docker-compose.yml --clobber📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| - name: Upload self-host compose to release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: gh release upload ${{ github.event.release.tag_name }} selfhost/docker-compose.yml --clobber | |
| - name: Upload self-host compose to release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ github.event.release.tag_name }} | |
| run: gh release upload "$RELEASE_TAG" selfhost/docker-compose.yml --clobber |
🧰 Tools
🪛 zizmor (1.26.1)
[error] 45-45: code injection via template expansion (template-injection): may expand into attacker-controllable code
(template-injection)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/deploy.yml around lines 42 - 45, Update the “Upload
self-host compose to release” step to pass the release tag through its env
configuration using a descriptive variable, then reference that shell
environment variable in the gh release upload command instead of interpolating
github.event.release.tag_name directly in run.
Source: Linters/SAST tools
Self-hosting moves from "clone the repo and build" to the pattern every established OSS project uses (researched against LiteLLM, Shlink, Umami, Immich, n8n, Uptime Kuma, Plausible, Vaultwarden): wget one compose file,
docker compose up -d, done.The artifact
selfhost/docker-compose.yml— runsghcr.io/spoo-me/spoo:${SPOO_VERSION:-latest}with bundled MongoDB + Redis (not host-exposed). Zero-edit boot; every knob is${VAR:-default}so a.envbeside the file or shell env overrides without editing it. The deploy workflow attaches it to every release, soreleases/latest/download/docker-compose.ymlalways matches the newest image.Fixes required to make zero-config honest
/health. Now passes through to the full app surface; SaaS mode (CUSTOM_DOMAINS_ENABLED=true, i.e. prod) keeps the strict 404, and already-registered domains keep resolving in both modes.InvalidKeyError). Boot now autogenerates an ephemeral HS256 secret with a loudjwt_secret_autogeneratedwarning; sessions don't survive restarts untilJWT_SECRETis set..env.exampleplaceholder JWT keys — the non-empty-----BEGIN PRIVATE KEY-----\n.....placeholders forced RS256 with garbage keys; first login crashed for anyone who kept them. Now empty with a comment explaining the HS256 fallback.Verified e2e
Booted the compose zero-edit with the built image: health ✓, shorten ✓, register returns a signed token ✓,
/healthvia a fake internal-domain Host header serves 200 ✓ (was sitewide 404). 1811 tests green.ghcr.io/spoo-me/spoois private — anonymous pulls fail (verified: token grant 401 vs Immich's 200). Nothing in this PR works for the public until: org Packages → spoo → Package settings → Change visibility → Public.Docs rewrite (docker-deployment.mdx, PaaS repairs) is the follow-up.
Summary by Sourcery
Introduce an image-based Docker self-hosting flow with a bundled compose file while ensuring zero-config deployments behave correctly for JWT auth and tenant routing.
New Features:
Bug Fixes:
Enhancements:
Build:
Tests:
Summary by CodeRabbit
New Features
Bug Fixes
Security