Add production Dockerfile and release image pipeline #2

Closed
opened 2026-08-23 15:27:52 +02:00 by myrmidex · 1 comment
Owner

Context

web/ has no production image definition — only Dockerfile.dev, which is a development
environment (bind-mounts the source, installs dev dependencies, runs Vite in watch mode,
boots with Xdebug available). Nothing exists that a self-hoster could pull and run.

This blocks documenting installation at all: the conventional self-hosted README points at a
published image, and we have none.

Scope

1. Production Dockerfile

  • FrankenPHP base, matching dev: dunglas/frankenphp:1-php8.4-alpine
    (note latest-php8.4-alpine does not exist — see .claude/PLATFORM.md)
  • Multi-stage: build assets with Node, then copy only the built output into the runtime layer
  • composer install --no-dev --optimize-autoloader
  • npm run build for Vite assets; no Vite dev server, no public/hot
  • No Xdebug, no dev tooling, source copied in rather than bind-mounted
  • Laravel production caches (config:cache, route:cache, view:cache)
  • Serve on :8000 internally, as dev does

2. Root compose.yaml for self-hosters

  • References the published image rather than building from source
  • app + db (MariaDB 11), no dev-only services
  • Bind published ports to 127.0.0.1 rather than 0.0.0.0 — the convention in
    comparable projects (Vaultwarden, Uptime Kuma both do this deliberately)
  • Healthcheck against /up (already wired in bootstrap/app.php:12)
  • Named volume for the database

3. Forgejo workflow.forgejo/workflows/build.yml

Mirror ffr/.forgejo/workflows/build.yml:

on:
  push:
    branches: [main]
    tags: ['v*']
  • Tag push v*forge.lvl0.xyz/anagram-finder/web:<tag> and :latest
  • Push to main:latest only
  • Login to forge.lvl0.xyz with secrets.REGISTRY_TOKEN
  • docker/build-push-action@v5, context: ., file: Dockerfile

4. Remove dead scaffolding

.github/workflows/tests.yml and .github/dependabot.yml came from the Laravel starter kit.
This repo is hosted on Forgejo, so they will never run. Delete, or port tests.yml to
.forgejo/workflows/ci.yml if we want lint/test gates now.

Prerequisites

  • REGISTRY_TOKEN secret must exist in the repo settings
  • Package registry write access for the anagram-finder org

Acceptance

  • Dockerfile builds a runnable production image locally
  • Container serves the app with assets present and no public/hot
  • Pushing tag v0.1.0 publishes web:v0.1.0 and web:latest
  • compose.yaml at the repo root brings the stack up from the published image alone
  • .github/ removed or ported

Notes

Image tag comes from the git tag, not the release branch — so the release flow stays
release/<milestone> → PR → merge to main → tag v* → image builds.

## Context `web/` has no production image definition — only `Dockerfile.dev`, which is a development environment (bind-mounts the source, installs dev dependencies, runs Vite in watch mode, boots with Xdebug available). Nothing exists that a self-hoster could pull and run. This blocks documenting installation at all: the conventional self-hosted README points at a published image, and we have none. ## Scope **1. Production `Dockerfile`** - FrankenPHP base, matching dev: `dunglas/frankenphp:1-php8.4-alpine` (note `latest-php8.4-alpine` does **not** exist — see `.claude/PLATFORM.md`) - Multi-stage: build assets with Node, then copy only the built output into the runtime layer - `composer install --no-dev --optimize-autoloader` - `npm run build` for Vite assets; no Vite dev server, no `public/hot` - No Xdebug, no dev tooling, source copied in rather than bind-mounted - Laravel production caches (`config:cache`, `route:cache`, `view:cache`) - Serve on :8000 internally, as dev does **2. Root `compose.yaml`** for self-hosters - References the published image rather than building from source - `app` + `db` (MariaDB 11), no dev-only services - Bind published ports to `127.0.0.1` rather than `0.0.0.0` — the convention in comparable projects (Vaultwarden, Uptime Kuma both do this deliberately) - Healthcheck against `/up` (already wired in `bootstrap/app.php:12`) - Named volume for the database **3. Forgejo workflow** — `.forgejo/workflows/build.yml` Mirror `ffr/.forgejo/workflows/build.yml`: ```yaml on: push: branches: [main] tags: ['v*'] ``` - Tag push `v*` → `forge.lvl0.xyz/anagram-finder/web:<tag>` **and** `:latest` - Push to `main` → `:latest` only - Login to `forge.lvl0.xyz` with `secrets.REGISTRY_TOKEN` - `docker/build-push-action@v5`, `context: .`, `file: Dockerfile` **4. Remove dead scaffolding** `.github/workflows/tests.yml` and `.github/dependabot.yml` came from the Laravel starter kit. This repo is hosted on Forgejo, so they will never run. Delete, or port `tests.yml` to `.forgejo/workflows/ci.yml` if we want lint/test gates now. ## Prerequisites - `REGISTRY_TOKEN` secret must exist in the repo settings - Package registry write access for the `anagram-finder` org ## Acceptance - [ ] `Dockerfile` builds a runnable production image locally - [ ] Container serves the app with assets present and no `public/hot` - [ ] Pushing tag `v0.1.0` publishes `web:v0.1.0` and `web:latest` - [ ] `compose.yaml` at the repo root brings the stack up from the published image alone - [ ] `.github/` removed or ported ## Notes Image tag comes from the git tag, not the release branch — so the release flow stays `release/<milestone>` → PR → merge to `main` → tag `v*` → image builds.
myrmidex added this to the v0.1.0 milestone 2026-08-23 15:27:52 +02:00
myrmidex added the
ci
label 2026-08-23 15:27:52 +02:00
myrmidex self-assigned this 2026-08-23 15:27:53 +02:00
myrmidex added a new dependency 2026-08-23 15:28:18 +02:00
Author
Owner

Done — web:v0.1.0 and :latest published

All acceptance criteria verified:

  • Dockerfile builds a runnable production image — three stages: composer vendor, node
    assets, FrankenPHP runtime
  • Container serves the app with assets present and no public/hot — verified against a
    running container: page 200, stylesheet 200 (265 KB, containing every @lvl0/ui utility and
    the @font-face), /hot → 404. End to end: POST / → 302 → /find/eamstoxil447
    matches
    from the dictionary in anagram-finder/core.
  • Tag push publishes web:v0.1.0 and :latest — run #14 on tag v0.1.0 (f267bac),
    run #13 on the preceding push to main
  • compose.yaml brings the stack up — verified locally against the built image
  • .github/ removed

Not in the original scope

web has two private dependencies — anagram-finder/core (composer) and @lvl0/ui (npm) —
both previously ssh://. A CI runner has no SSH key for Forgejo, so the build could not have
worked. Both repos allow anonymous read, so both manifests now use https:// and the build
needs no secret beyond the registry push token.

Bugs found by running it

  1. Stage ordering. Assets were built before vendor, but resources/css/app.css imports
    Flux's stylesheet from vendor/ and scans three vendor globs for class names. Reordered to
    vendor → assets → runtime.
  2. dump-autoload triggered Laravel's post-autoload-dump hook, which runs artisan and
    needs a runtime environment. Added --no-scripts; package:discover happens at boot.
  3. bootstrap/cache/*.php leaked in from the host, registering laravel/pail's provider —
    a dev dependency --no-dev never installs, so every artisan call fatalled and the container
    crash-looped. Excluded from the build context, and the boot script now rebuilds the manifest.
  4. depends_on: condition: service_healthy hangs podman-compose 1.5.0 indefinitely, even
    with the database reporting healthy. Switched to a plain depends_on; the start script polls
    for the database itself.

Notes for #3 (README)

  • Image: forge.lvl0.xyz/anagram-finder/web:v0.1.0
  • compose.yaml at the repo root is what the README should link rather than inline
  • .env.example gained the DB variables; it previously defaulted to sqlite, so a self-hoster
    copying it would have had nothing for compose to read
  • Required env: APP_KEY, APP_URL, DB_PASSWORD, DB_ROOT_PASSWORD
  • Build takes ~10 minutes

Leftover

The stale tests.yml #12 run is queued forever — it was triggered by 49fe4bf, before this
ticket deleted .github/. Forgejo has no runner matching its runs-on. Harmless, and nothing
will queue again; cancel it in the Actions tab if the queued state is untidy.

## Done — `web:v0.1.0` and `:latest` published All acceptance criteria verified: - **Dockerfile builds a runnable production image** — three stages: composer vendor, node assets, FrankenPHP runtime - **Container serves the app with assets present and no `public/hot`** — verified against a running container: page 200, stylesheet 200 (265 KB, containing every `@lvl0/ui` utility and the `@font-face`), `/hot` → 404. End to end: `POST /` → 302 → `/find/eamstoxil` → **447 matches** from the dictionary in `anagram-finder/core`. - **Tag push publishes `web:v0.1.0` and `:latest`** — run #14 on tag `v0.1.0` (`f267bac`), run #13 on the preceding push to main - **`compose.yaml` brings the stack up** — verified locally against the built image - **`.github/` removed** ### Not in the original scope `web` has two private dependencies — `anagram-finder/core` (composer) and `@lvl0/ui` (npm) — both previously `ssh://`. A CI runner has no SSH key for Forgejo, so the build could not have worked. Both repos allow anonymous read, so both manifests now use `https://` and the build needs no secret beyond the registry push token. ### Bugs found by running it 1. **Stage ordering.** Assets were built before vendor, but `resources/css/app.css` imports Flux's stylesheet from `vendor/` and scans three vendor globs for class names. Reordered to vendor → assets → runtime. 2. **`dump-autoload` triggered Laravel's post-autoload-dump hook**, which runs `artisan` and needs a runtime environment. Added `--no-scripts`; `package:discover` happens at boot. 3. **`bootstrap/cache/*.php` leaked in from the host**, registering `laravel/pail`'s provider — a dev dependency `--no-dev` never installs, so every artisan call fatalled and the container crash-looped. Excluded from the build context, and the boot script now rebuilds the manifest. 4. **`depends_on: condition: service_healthy` hangs podman-compose 1.5.0** indefinitely, even with the database reporting healthy. Switched to a plain `depends_on`; the start script polls for the database itself. ### Notes for #3 (README) - Image: `forge.lvl0.xyz/anagram-finder/web:v0.1.0` - `compose.yaml` at the repo root is what the README should link rather than inline - `.env.example` gained the DB variables; it previously defaulted to sqlite, so a self-hoster copying it would have had nothing for compose to read - Required env: `APP_KEY`, `APP_URL`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` - Build takes ~10 minutes ### Leftover The stale `tests.yml #12` run is queued forever — it was triggered by `49fe4bf`, before this ticket deleted `.github/`. Forgejo has no runner matching its `runs-on`. Harmless, and nothing will queue again; cancel it in the Actions tab if the queued state is untidy.
Sign in to join this conversation.
No labels
bug
ci
docs
enhancement
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Blocks
Reference: anagram-finder/web#2
No description provided.