chore: Dockerfile, Gitea CI image build, docker-compose example

The server now stops on SIGTERM/SIGINT so docker stop is immediate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-15 10:45:13 +02:00
co-authored by Claude Opus 5.5
parent 2282897b4b
commit ec51b7b5fc
9 changed files with 227 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
.git
.gitea
.claude
.codex
.gemini
.impeccable
.superpowers
.worktrees
node_modules
dist
samples
docs
test
*.md
.env
.env.*
.dev.vars
docker-compose*.yml
Dockerfile
+99
View File
@@ -0,0 +1,99 @@
name: Continuous Integration
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
REGISTRY: gitea.lucaswinther.info
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Run test suite
run: bun test
- name: Build page and userscript
run: bun run build
docker-build-and-push:
runs-on: ubuntu-latest
needs: [lint-and-test]
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Gitea's job token cannot publish packages; use a package-write PAT.
- name: Log in to Gitea Container Registry
if: gitea.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ gitea.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=sha-
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
build-args: |
BUILD_VERSION=${{ gitea.sha }}
BUILD_REPOSITORY=https://gitea.lucaswinther.info/${{ gitea.repository }}
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Read-only smoke test: no storage, no outbound calls; the page, config and userscript must be served.
- name: Pull and smoke-test published image
if: gitea.event_name != 'pull_request'
env:
PUBLISHED_TAGS: ${{ steps.meta.outputs.tags }}
run: |
image="$(printf '%s\n' "$PUBLISHED_TAGS" | head -n 1)"
docker pull "$image"
docker run -d --rm --name mangabaka-seeder-ci "$image"
trap 'docker stop mangabaka-seeder-ci' EXIT
docker exec mangabaka-seeder-ci bun -e '
const base = "http://127.0.0.1:8787";
let ready = false;
for (let attempt = 0; attempt < 30; attempt++) {
try {
const response = await fetch(base + "/");
if (response.ok && (await response.text()).includes("MangaBaka Seeder")) {
ready = true;
break;
}
} catch {}
await Bun.sleep(1000);
}
if (!ready) throw new Error("App did not serve its HTML");
const config = await (await fetch(base + "/api/config")).json();
if (config.storage !== false) throw new Error("expected no storage without MBSEED_STORAGE_DIR");
const script = await fetch(base + "/mbseed.user.js");
if (!script.ok || !(await script.text()).startsWith("// ==UserScript==")) throw new Error("/mbseed.user.js missing");
const refused = await fetch(base + "/api/fetch?url=https://example.org/");
if (refused.status !== 403) throw new Error(`/api/fetch without x-mbseed returned ${refused.status}`);
'
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
# mangabaka-seeder: the page, the userscript and the Bun server in one image.
# Configure with MBSEED_* environment variables (docs/ARCHITECTURE.md, "Running the server").
FROM oven/bun:1.3.14-slim AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY tsconfig.json ./
COPY src ./src
COPY scripts ./scripts
RUN bun run build:web
FROM oven/bun:1.3.14-slim
ARG BUILD_VERSION=dev
ARG BUILD_REPOSITORY=https://gitea.lucaswinther.info/lucasw89/mangabaka-seeder
LABEL org.opencontainers.image.title="mangabaka-seeder" \
org.opencontainers.image.description="Seed WEBTOON covers into MangaBaka cover edit requests" \
org.opencontainers.image.source="${BUILD_REPOSITORY}" \
org.opencontainers.image.revision="${BUILD_VERSION}"
WORKDIR /app
# No runtime dependencies: the server, the page and the userscript build need only Bun.
COPY --from=build /app/package.json ./
COPY --from=build /app/src ./src
COPY --from=build /app/scripts ./scripts
COPY --from=build /app/dist ./dist
# Storage is off unless MBSEED_STORAGE_DIR is set (with MBSEED_PUBLIC_URL); /data/covers is the conventional place.
RUN mkdir -p /data/covers && chown -R bun:bun /data /app/dist
ENV MBSEED_HOST=0.0.0.0 \
MBSEED_PORT=8787 \
MBSEED_ASSETS_DIR=/app/dist/web
USER bun
EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["bun", "-e", "fetch('http://127.0.0.1:' + (process.env.MBSEED_PORT ?? 8787) + '/api/config').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
# The userscript is built at start so its @connect list includes MBSEED_PUBLIC_URL's host.
CMD ["sh", "-c", "bun run scripts/build-userscript.ts && exec bun run src/server/bun.ts"]
+13
View File
@@ -101,6 +101,19 @@ its own storage and the page offers litterbox, catbox and no rehosting. All
settings, and how to put it behind a reverse proxy, are in
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#running-the-server).
### With Docker
Every push to `main` builds an image and publishes it to
`gitea.lucaswinther.info/lucasw89/mangabaka-seeder` (`latest`, the branch name,
`sha-<commit>`, and the version for `v*` tags). Copy
[`docker-compose.example.yml`](docker-compose.example.yml) to `docker-compose.yml`,
set `MBSEED_PUBLIC_URL`, and run `docker compose up -d`. To build it yourself:
`docker build -t mangabaka-seeder .`
The CI workflow (`.gitea/workflows/ci.yml`) publishes with a repository or user
secret `REGISTRY_TOKEN`: a Gitea access token with package write permission
(Gitea's job token cannot publish packages).
### The userscript
Install `/mbseed.user.js` from the running server (or `dist/web/mbseed.user.js`)
+26
View File
@@ -0,0 +1,26 @@
# Example: run mangabaka-seeder behind your reverse proxy.
# Copy to docker-compose.yml, set MBSEED_PUBLIC_URL to the address your proxy serves, then:
# docker compose up -d
# All settings: docs/ARCHITECTURE.md, "Running the server".
services:
mangabaka-seeder:
image: gitea.lucaswinther.info/lucasw89/mangabaka-seeder:latest
restart: unless-stopped
# Only the reverse proxy should reach it; it terminates TLS and appends X-Forwarded-For.
ports:
- "127.0.0.1:8787:8787"
environment:
# Public origin (no path). Stored covers are served from it, and the userscript may connect to it.
MBSEED_PUBLIC_URL: https://mbseed.example.org
# Rehost covers on this server for 30 days. Remove both lines to run without storage
# (the page then offers litterbox, catbox and no rehosting).
MBSEED_STORAGE_DIR: /data/covers
MBSEED_STORAGE_MAX_BYTES: "1073741824" # 1 GiB; uploads past it get 507, nothing is evicted early
# Rate-limit on the address your proxy appends to X-Forwarded-For. Leave unset without a proxy.
MBSEED_TRUST_PROXY: "1"
volumes:
# A named volume works as is. For a bind mount, make the directory writable by uid 1000 (the image's "bun" user).
- covers:/data/covers
volumes:
covers:
+7
View File
@@ -42,6 +42,13 @@ user edits them, then `rehost` (explicit) → `manualText` for the cover form, o
Put it behind a reverse proxy that terminates TLS and **appends** the client address to
`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, Caddy's default), then set `MBSEED_TRUST_PROXY=1`.
**Docker.** `Dockerfile` builds the page in one stage and runs the server as user `bun` (uid 1000) on
`0.0.0.0:8787`; `MBSEED_ASSETS_DIR=/app/dist/web`. Storage stays off unless `MBSEED_STORAGE_DIR` is set
(`/data/covers` exists for it). The container builds the userscript at start, so its `@connect` list
includes `MBSEED_PUBLIC_URL`'s host. `bun.ts` stops on SIGTERM/SIGINT (Bun as PID 1 ignores them otherwise).
`.gitea/workflows/ci.yml` typechecks, tests and builds, then builds and pushes the image (not for pull
requests) and smoke-tests it. Example deployment: `docker-compose.example.yml`.
## External dependencies
| Service | Reached via | If unavailable |
|---|---|---|
+17
View File
@@ -16,6 +16,23 @@ the repo every session — measurably slower and more tokens).
**Consequences:** Rule changes happen in one file; pointer files must stay thin. Plans are working
artefacts: kept after merge they go stale and contradict the code, so the spec is the record.
## 2026-10-15 — Docker image built by Gitea CI
**Status:** accepted
**Context:** The user wants a Dockerfile that Gitea builds automatically, plus a docker-compose example,
mirroring the working workflow in their `budget2.0` repository.
**Decision:** Two-stage image on `oven/bun:1.3.14-slim` (no runtime dependencies, so only `src`, `scripts`
and the built page are copied). It runs as `bun`, listens on `0.0.0.0:8787`, and has a healthcheck on
`/api/config`. Storage is off by default; the compose example turns it on in a named volume at `/data/covers`.
The userscript is built when the container starts, not in the image, because its `@connect` list depends on
`MBSEED_PUBLIC_URL`, which is only known at run time. `bun.ts` handles SIGTERM/SIGINT: without it Bun as
PID 1 ignored `docker stop` and was killed after 10 s. The workflow copies budget2.0: tests first, then
Buildx, push to the Gitea registry with a `REGISTRY_TOKEN` secret (a package-write token; Gitea's job
token cannot publish packages), and a read-only smoke test of the pushed image.
**Alternatives:** Building the userscript in the image (wrong `@connect` for every deployment);
a distroless or compiled-binary image (smaller, but harder to debug, and Bun's slim image is enough).
**Consequences:** The `REGISTRY_TOKEN` secret must exist in Gitea before the image job can push. Starting
the container takes about a second longer for the userscript build.
## 2026-10-15 — Harden the server proxy and uploads (final review)
**Status:** accepted (amends "Implementation details: server API" below)
**Context:** The whole-branch review found that `/api/fetch` passed the upstream content type through. Anyone can
+2
View File
@@ -8,6 +8,8 @@ Design: [specs/001-webtoons-covers.md](specs/001-webtoons-covers.md).
- [ ] **User smoke test** in a real browser: the page (local server) and the userscript in Tampermonkey or
Violentmonkey on a MangaBaka series page — Tower of God (9999 / title_no 95). The agent ran both in
headless Chrome (the userscript with a GM shim); a real GM install is still untested.
- [ ] **Gitea secret `REGISTRY_TOKEN`** (needs the user): an access token with package write permission, as a
repository or user secret, so CI can push the image. Then check the first run of `.gitea/workflows/ci.yml`.
- [ ] **Deploy** (needs the user): which server and domain (`MBSEED_PUBLIC_URL`), where the covers folder lives,
whether the 1 GiB cap suits the disk, reverse proxy with TLS that appends `X-Forwarded-For`.
+7
View File
@@ -37,3 +37,10 @@ console.log(
storage ? `${config.storageDir} (${storage.usedBytes()} of ${config.storageMaxBytes} bytes)` : "off"
}`,
);
// In a container Bun runs as PID 1, which ignores SIGTERM without a handler: stop cleanly on docker stop.
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.on(signal, () => {
void server.stop().finally(() => process.exit(0));
});
}