feat(docker): all-in-one image (#1042) — my version of #1067 (#1068)

* feat(docker): add all-in-one image — backend + frontend in one container (#1042)

One container, one Node process, SQLite by default: `docker run` with no
compose file, no nginx, no supervisor, no bundled Postgres/Redis.

- Dockerfile.aio (repo-root context): frontend build stage + backend deps
  stage + a runtime stage mirroring backend/Dockerfile's production stage,
  with the built SPA copied to /app/frontend/dist and SERVE_FRONTEND=true.
  DATABASE_CLIENT=sqlite3 and STORAGE_PATH=/app/storage are pinned
  explicitly — the storage fallback resolves to container-root /storage,
  which EACCESes after the su-exec drop.
- server.js: the SERVE_FRONTEND block now does what the nginx image did —
  renders ${BRAND_TITLE}/${BRAND_DESCRIPTION} into index.html once at boot,
  serves that rendered shell on /index.html and every SPA route, caches
  hashed /assets/* immutably while the shell revalidates, and gzips the
  bundle via compression() mounted after all /api routers. express.static
  now runs with index:false so `/` keeps flowing to handlePublicSiteRequest
  — its default index option was shadowing the landing page on native
  installs.
- wait-for-db.sh: skip the Postgres readiness wait when DATABASE_CLIENT is
  sqlite3. The engine resolver still runs, still logs, and still refuses
  the populated-both conflict (#1038).
- .dockerignore: **/node_modules, so the root-context build can't pick up
  host deps from backend/ or frontend/.
- docker-build.yml: build-aio / merge-aio follow the same per-arch build →
  digest-merge → per-version tag scheme as backend/frontend (GHCR only for
  now; the Docker Hub mirror is wired once the Hub repo exists), plus a
  smoke-aio job that boots the image on every PR and asserts /health, the
  SPA shell, the rendered brand title, immutable asset caching and the
  SQLite engine resolution.

Pointing DB_HOST/DB_USER/DB_PASSWORD + DATABASE_CLIENT=pg at an external
Postgres works exactly like the backend image.

* fix(ci): correct three smoke-aio assertions that would fail a green image (#1042)

Found by running the smoke job locally against a real build — the image
passed every behavioral check, but three assertions were wrong:

- `/` asserts 200, but handlePublicSiteRequest 302s to /admin/login while
  the public landing site is disabled, which is the state of the fresh
  install the smoke container always is. Assert the redirect target
  instead — that still proves express.static's index option is not
  shadowing the handler, which is the thing the check exists for.
- The placeholder-leak grep matched index.html's explanatory comment,
  which mentions BRAND_TITLE in prose and survives into the built shell.
  Match the literal ${BRAND_TITLE}/${BRAND_DESCRIPTION} tokens with -F,
  and cover the description token too.
- Add a gzip assertion, probing with GET: the compression middleware
  skips bodyless responses, so a HEAD probe reports no Content-Encoding
  even when compression is active.

Verified locally on linux/arm64: image builds clean, boots to healthy in
~8s on the SQLite default, and 25/25 checks pass (SPA shell, rendered
brand title, immutable+gzipped assets, no-store shell, SPA fallbacks,
npm removed, su-exec drop to nodejs, no errors in the boot log). The
DATABASE_CLIENT=pg override was exercised against a real Postgres too —
the readiness wait still runs and the engine resolves to postgres.

* fix(server): serve the SPA for every client route, not just /admin and /gallery (#1042)

nginx did `try_files $uri $uri/ /index.html`, so behind compose every
client-side route survived a direct hit or a refresh and the short
`['/admin', '/admin/*', '/gallery/*']` list was never exercised. Without
nginx that list is the whole contract, and everything outside it 404'd:

  /setup  /customer  /impressum  /datenschutz  /payment-check
  /quote/:token  /contract/:token  /invite/:token
  /transfer/:token  /transfer-upload/:token

/setup is the first URL a new install visits, so the all-in-one image was
unusable from a cold start.

The catch-all is registered after `app.use('/api', notFoundHandler)`, so
an unknown /api route still answers JSON instead of being handed the HTML
shell, and after the /s/:shortSlug resolver, so a typo'd short URL still
404s (#699). It is GET-only — a stray POST keeps 404ing rather than
getting a 200 page back. The handler is hoisted out of the
SERVE_FRONTEND block via `spaCatchAll` because that block runs before the
API 404 handler is registered.

Verified on the built image: all ten routes above now 200, /api/nope still
returns JSON 404, /s/nonexistent still returns 404, / still 302s to
/admin/login, and the smoke suite is 25/25. Both boundaries are now
asserted in the smoke-aio job.

* docs(readme): document the single-container install (#1042)

The README had no mention of the all-in-one image, so the only way to
discover it was reading the workflow file. Adds a Quick Start subsection
with the one-line `docker run` and the `docker exec … cat SETUP_TOKEN`
step, plus a row in the documentation table.

Deliberately does not sell it as the default: the note says the compose
stack is still the right choice for anything busier, gives the reason
(SQLite takes one writer at a time), and points at the `.picpeak`
restore as the way out, so nobody picks it and then finds themselves
stuck. Full details live at docs.picpeak.app/deployment/single-container
(PicPeak/docs#8).

* feat(docker): fold #1067's items into the all-in-one image (#1042)

Consolidating the two parallel AIO branches into this one. This PR's approach
is kept wherever the two differed on design — in particular the in-process
brand render, `index: false` (which fixes express.static shadowing
handlePublicSiteRequest, a bug #1067 had), the compression middleware, and the
smoke-aio job. What follows is what #1067 had that this branch did not.

Layout — the issue asks for a single mountable root, and this moves to one:

  /data/db       picpeak.db (+ -wal/-shm) and SETUP_TOKEN
  /data/storage  originals, thumbnails, archives
  /data/logs     application logs
  /data/backup   built-in backup output; /backup symlinks here

`-v picpeak:/data` and nothing else to remember. README and the smoke job's
database-path assertion follow the new layout.

Correctness items:

- sqlite CLI. DatabaseBackupService SPAWNS `sqlite3` for `.backup` and
  PRAGMA integrity_check; the npm module does not ship that binary.
  backend/Dockerfile omits it because compose always runs Postgres — this
  image defaults to SQLite, so every database backup failed with ENOENT.
- /backup wired in. Migrations 029 + 030 seed /backup/picpeak and
  /backup/database as the backup destinations; nothing created or mounted them,
  so backups had nowhere to write and anything written would die with the
  container. Symlinked into the volume, subdirectories created at startup
  (a bind mount hides the tree baked into the image), and adopted only when
  BACKUP_DIR is set so it never gates boot for compose deployments that do not
  mount it.
- logger.js honours LOG_DIR. It hard-coded <backend>/logs, so logs could not
  leave the container. Unset keeps the old path for every existing install.
- wait-for-db.sh derives its writable roots from STORAGE_PATH / DATA_DIR /
  LOG_DIR instead of hard-coded /app paths, and mkdir -p's them before chown —
  a bind-mounted /data hides the image's tree, and chown against a missing path
  reports "the filesystem rejects chown", which is both wrong and a dead end.
- .dockerignore excludes backend/-prefixed runtime data. Docker reads only the
  root file, so the unprefixed data/*.db, logs/* and storage/* rules missed
  backend/data, backend/logs and backend/storage entirely; a checkout used to
  run PicPeak would bake its database, photos, logs and SETUP_TOKEN into a
  published layer.
- HEALTHCHECK follows $PORT rather than a hard-coded 3000.
- --max-http-header-size=32768 matches nginx's large_client_header_buffers
  4 32k; Node's 16 KiB default would reject a guest carrying several
  per-gallery JWT cookies.

docs/single-container.md is added as the in-repo reference the README links to.

The smoke job gains four assertions for the above: the one-volume layout and
writable backup destinations, the sqlite3 CLI, logs landing on the volume, and
the image carrying no runtime data from the build context.

Verified on a built image — named volume, bind mount and PORT=8080 all healthy;
every existing smoke assertion still passes, including / -> 302 /admin/login,
the rendered BRAND_TITLE, immutable assets, gzip and /s/<unknown> -> 404.

Co-authored-by: Luca-Timo <102960244+Luca-Timo@users.noreply.github.com>

* fix(docker): restore the SPA-fallback exclusions and close the build-context leak (#1042)

Both found by external review of the consolidated branch.

- The SPA catch-all had no backend-owned exclusions. This was a regression I
  introduced while merging: #1067 carried a BACKEND_OWNED prefix list, and
  taking this branch's server.js wholesale (correctly — its index:false and
  in-process brand render are the better design) dropped it. /photos,
  /thumbnails, /uploads and /fonts are static mounts whose middleware calls
  next() on a miss, so the catch-all was answering 200 text/html under image
  and font URLs instead of 404. nginx gave each of those its own location
  block, so try_files never applied to them.

- backend/data is now excluded wholesale rather than by suffix. The suffix list
  (*.db, *.db-wal, *.db-shm, SETUP_TOKEN) let real secrets through: a used
  checkout carries ADMIN_CREDENTIALS.txt next to the database, plus -journal
  files and any DATABASE_PATH not ending in .db. Since Dockerfile.aio builds
  from the repository root and COPYs backend/ wholesale, any of those would be
  baked into a published layer. The directory holds only runtime state and is
  already gitignored in full.

smoke-aio gains an assertion that the backend static routes still 404, so the
exclusion cannot be dropped again silently.

Verified on a built image: /photos, /thumbnails, /fonts and /uploads misses all
404; /setup, /impressum, /gallery/x, /admin/login still 200; / still 302s to
/admin/login; /api/nope still answers JSON; /s/<unknown> still 404s; and the
image carries no *.db, ADMIN_CREDENTIALS.txt, logs or storage from the context.

* fix(aio): three failures that only surface outside a dev laptop (#1042)

Backups aborted on SQLite. getTableChecksums() built its digest with
`CAST(t.* AS TEXT)`, which is Postgres row-to-text syntax; SQLite parses
`*` there as a syntax error, so every backup threw before reaching the
.backup call. Since the all-in-one image ships SQLite by default, that is
every AIO install. Enumerate the columns via columnInfo() and sum their
lengths instead.

The shared /data mount root was never adopted. wait-for-db.sh chowned the
children it creates but not the mount point itself, so a host directory
arriving as 0700 with a foreign owner stayed untraversable by UID 1001
after the su-exec drop. Docker Desktop's permissive bind mounts hide this
completely, which is why local testing passed; a NAS share does not.
DATA_ROOT is now adopted first.

Maintenance mode locked the admin out of the box. The middleware runs at
server.js:493, long before the static block at 891, and exempted the auth
endpoints but not the page that calls them. With the backend serving the
frontend, /admin/login and /assets/* returned 503 JSON, so an admin who
enabled maintenance mode could never load the UI to turn it off. nginx
serves those paths in the compose stack, which is why it never surfaced
there. Guest and API surfaces stay gated.

Verified on a built image: checksums compute across all 95 tables; a bind
mount created 0700/4000:4000 boots healthy and ends up 1001:1001; with
general_maintenance_mode=true, /admin/login, /admin and /assets/* return
200 while /gallery/* and /api/gallery/* return 503 — and 503 across all
three once the exemption is removed again.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(aio): stop leaking .env into the image, fix the broken checksum test (#1042)

The Jest suite was red: mocking db.raw is no longer enough now that the
SQLite checksum branch asks the query builder for its column list, so
db(table) came back undefined and getTableChecksums failed on every PR.
The production code is right; the fixture needed to know about the call.

backend/.env was landing in the published layer. The root ignore file's
`.env`, `.env.*` and `data/*.db` rules read as unanchored but Docker
matches them from the context root, so they catch ./.env and never
backend/.env — and `COPY backend/ .` then puts a real JWT_SECRET at
/app/.env. Matched at any depth instead, the way **/node_modules in the
same file already is. Confirmed by building from a checkout carrying a
planted secret: before, `cat /app/.env` printed it back.

Business documents wrote outside the volume. quoteService, invoice
sending/reminders and contract signatures build paths from
process.cwd()/storage and never read STORAGE_PATH; compose hides it by
setting STORAGE_PATH=/app/storage with WORKDIR /app so the two are the
same directory. Here they are not, and /app is root-owned, so a quote or
invoice PDF failed to write as UID 1001 — and would not survive the
container if it had. Symlinked /app/storage into the volume, matching
the /backup symlink beside it. Teaching those services STORAGE_PATH is
the real fix and wants its own change.

Two smaller ones: the mount root is now chowned shallow rather than
recursively, since every child below it is already walked recursively
and a NAS-sized photo library should not be traversed twice on each
restart; and /assets/ joins the backend-owned prefixes, so a stale
hashed chunk requested by a tab left open across an upgrade gets a 404
instead of index.html served with 200 under a .js URL.

Verified on a built image: planted backend/.env and backend/probe.db are
absent; /app/storage resolves to /data/storage and a business-doc write
as UID 1001 appears on the host; a 0700 bind mount owned by 4000:4000
boots healthy; a missing /assets chunk 404s while the real bundle still
serves 200 as application/javascript. The databaseBackup suite is green
again, and the branch adds no failing suite that origin/main does not
already fail on the same machine.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* test(aio): teach the leak assertion about the storage symlink (#1042)

The previous check listed /app/storage/events and treated a hit as a
leak. That was true while /app/storage was either absent or a copied
directory; now it is a symlink into the volume, so the check followed it
and found the empty tree the image itself creates — a false positive on
its own design.

Check the shape instead: /app/storage must be a symlink pointing at
/data/storage, and the volume's photo tree must contain no files on a
fresh install. A real directory there now fails loudly, which is the
condition the assertion was always trying to catch. Also extended the
path list to /app/.env and loose database files, matching the
.dockerignore rules added alongside.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(aio): show the maintenance screen instead of raw JSON to guests (#1042)

The previous commit exempted the admin shell so an admin could still
reach the switch they had just flipped. Guests had the same problem for
the same reason: with no nginx in front, /gallery/<slug> reaches this
middleware long before the static block, so a visitor during maintenance
got a 503 JSON body where every other deployment shows the branded
maintenance screen the frontend already ships.

Replaced the two path-specific exemptions with the rule they were both
special cases of: a GET that is not an API call and not a backend-owned
content mount is the SPA shell, and the shell is inert HTML — it boots,
reads /api/public/settings (already exempt) and renders MaintenanceMode
on its own. Everything that carries real data stays gated: /api/*,
/photos/, /thumbnails/, /fonts/, and any non-GET.

Compose is untouched by construction, since nginx answers those paths
and they never arrive here.

Verified on a built image with the flag on: /gallery/x, /customer/x,
/admin and /admin/login return 200 text/html while /api/gallery/x/verify,
/photos/x.jpg and /thumbnails/x.jpg return 503 and a POST to a public API
still returns 503; with the flag off the same paths go back to 404. Added
a middleware test over that exemption matrix — over-exemption is the real
risk in this change, so it asserts the gated half too. It fails on five
cases without the fix.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(aio): stop the shell exemption from un-gating /og and the public CMS (#1042)

The previous commit exempted "any GET that is not an API call". That
negative rule reads as safe and is not: /og/gallery/<slug> and its
/cover render the event name and the hero thumbnail, /s/<code> renders
short-link previews, and `/` is handed to the public CMS. All four are
proxy_passed to the backend by nginx, so they were gated before this PR
in every deployment — the rule un-gated them, and for compose too, not
just the new image. A site switched to maintenance would have kept
publishing gallery metadata.

Replaced the guess with the split nginx already defines: exempt what the
frontend container answers itself, gate what it proxies. That is the
same rule the all-in-one image needs by definition, since its whole job
is to be both halves of that stack, and it now matches compose in both
directions rather than only in the direction the last commit tested.

Verified on a built image with the flag on: /admin/login,
/gallery/<slug> and /customer/* return 200, while /, /og/gallery/x,
/og/gallery/x/cover, /s/abc, /robots.txt, /api/* and /photos/* return
503; with the flag off all of them behave normally again. The middleware
test grew the gated cases — it now covers 21, most of them asserting
what must NOT be exempt.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(aio): give the image a FRONTEND_URL default so share links are absolute (#1042)

getFrontendBaseUrl() reads FRONTEND_URL, falls back to the
general_site_url setting, and otherwise returns an empty string — which
makes share_url come back as a bare "/gallery/<slug>/<token>". Compose
defaults the variable to http://localhost:3000, but the documented
one-liner for this image passes only JWT_SECRET, so every fresh
single-container install handed out relative links in API responses, QR
codes and emails.

Defaulted to the same value compose uses; -e FRONTEND_URL=https://...
overrides it, as does the site URL field in Settings.

Found by pointing tests/e2e/local at a running AIO container:
auth/06-api-tokens asserts share_url matches /^https?:\/\//, and it was
the one spec that failed for a product reason rather than a harness one.
It passes now, and the suite is 19/20 against the image — the remaining
failure is smoke/02-auth-flow, whose seed helper shells out to a
hard-coded `docker exec picpeak-backend`, so it cannot arrange its
precondition against any other container.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* feat(aio): mark the image so face recognition stays off (#1042, #1074)

Face recognition needs a separate ML container this image does not contain,
and enabling it here would add a second image-processing pipeline competing
with Sharp for the CPU and memory of a container sized for one photographer
plus guests browsing. The failure mode would not be a clear error — just a
slow install that looks broken.

The backend gate for this lands in #1075 and keys on PICPEAK_SINGLE_CONTAINER.
Without this line the guard never triggers on an actual all-in-one build, so
the two changes have to arrive together: whichever merges second completes
the pair. Verified against this file's exact value — isFeatureEnabled()
returns false with it set.

An explicit marker rather than inferring from SERVE_FRONTEND or the SQLite
path, because legitimate multi-container deployments do both of those and
should keep the feature.

Also adds it to the Limits section of docs/single-container.md, next to the
SQLite and Redis constraints, since that is where someone will look before
choosing this image.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: the-luap <paul-nothaft@hotmail.de>
This commit is contained in:
Luca
2026-08-18 23:15:49 +03:00
committed by GitHub
parent f22999aba6
commit 0874a30ac9
15 changed files with 1195 additions and 31 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
# Docker Build and Push Workflow
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
This GitHub Actions workflow automatically builds and pushes Docker images for the backend, the frontend, and the all-in-one image to GitHub Container Registry (ghcr.io).
The **all-in-one image** (`<repo>/aio`, built from `Dockerfile.aio` at the repo root, #1042) bundles the backend and the built frontend into a single container with SQLite as the default engine — one `docker run`, no compose. It follows the same per-arch build → digest-merge → per-version tag scheme as the other two images, is currently GHCR-only (the Docker Hub mirror gets wired later), and every PR additionally runs a `smoke-aio` job that boots the image and asserts the SPA shell, brand-title rendering, immutable asset caching, and the SQLite engine resolution.
## Features
+403 -1
View File
@@ -599,8 +599,388 @@ jobs:
run: |
docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }}
# -----------------------------------------------------------------------------
# All-in-one (#1042): backend + built frontend in one container, SQLite default.
# Same per-arch build → digest merge pattern as backend/frontend. Context is
# the repo root (Dockerfile.aio needs backend/ AND frontend/).
# -----------------------------------------------------------------------------
build-aio:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
# Per-arch Trivy scan by digest, same rationale as build-backend (#476).
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Compute image name (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV"
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for AIO (labels only)
id: meta-aio
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak All-in-one
org.opencontainers.image.description=PicPeak backend + frontend in a single container (SQLite default)
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build AIO image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.aio
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-aio.outputs.labels }}
cache-from: type=gha,scope=aio-${{ env.PLATFORM_PAIR }}
# ignore-error: a flaky GitHub Actions cache write must not fail an
# otherwise-successful build that already pushed the image.
cache-to: type=gha,mode=max,scope=aio-${{ env.PLATFORM_PAIR }},ignore-error=true
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.AIO_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-aio.outputs.version }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-aio-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/trivy-action@v0.36.0
env:
# See build-backend — pin Trivy's platform to the matrix arch so its
# remote-index resolver picks the right child.
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-aio-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: steps.push-decision.outputs.push == 'true'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-aio-${{ env.PLATFORM_PAIR }}.sarif'
category: 'aio-vulnerabilities-${{ env.PLATFORM_PAIR }}'
merge-aio:
needs: build-aio
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
- name: Compute image name (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV"
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-aio-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
# Same per-version tag scheme as backend/frontend: every Release Please
# version publishes a matching aio image. GHCR-only for now — the Docker
# Hub mirror (docker.io/picpeak/aio) is wired later once the Hub repo
# exists: add the images line + Docker Hub login exactly like
# merge-backend (#1042).
- name: Extract metadata for AIO
id: meta-aio
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak All-in-one
org.opencontainers.image.description=PicPeak backend + frontend in a single container (SQLite default)
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim, same as backend/frontend.
type=ref,event=tag
type=sha,format=short
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest (GHCR)
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}:${{ steps.meta-aio.outputs.version }}
# Boot-level verification of the AIO image on every PR: build for the
# runner's arch, run it with no DB env (SQLite default), and assert the
# things nginx used to guarantee — SPA shell with the brand title rendered,
# immutable asset caching, /health green, and the resolver landing on
# SQLite. Mirrors the install-smoke workflow's build pattern.
smoke-aio:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build AIO image (single arch)
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.aio
load: true
tags: picpeak-aio:smoke
cache-from: type=gha,scope=aio-linux-amd64
cache-to: type=gha,mode=max,scope=aio-linux-amd64,ignore-error=true
- name: Boot container (SQLite default, no volumes)
run: |
docker run -d --name aio -p 3000:3000 \
-e JWT_SECRET=smoke-test-secret-at-least-32-characters-long \
-e BRAND_TITLE="AIO Smoke" \
picpeak-aio:smoke
- name: Wait for /health
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:3000/health > /dev/null 2>&1; then
echo "healthy after ~$((i*2))s"; exit 0
fi
sleep 2
done
echo "::error::/health never came up"; docker logs aio | tail -100; exit 1
- name: Assert engine resolved to SQLite
run: |
docker exec aio ls -la /data/db/picpeak.db
docker logs aio 2>&1 | grep -i "sqlite" | head -5
- name: Assert SPA shell served with rendered brand title
run: |
body=$(curl -fsS http://localhost:3000/admin)
echo "$body" | grep -q '<div id="root">' || { echo "::error::/admin did not serve the SPA shell"; exit 1; }
echo "$body" | grep -q '<title>AIO Smoke</title>' || { echo "::error::BRAND_TITLE was not rendered into index.html"; exit 1; }
# -F on the literal token: index.html's explanatory comment mentions
# BRAND_TITLE in prose and Vite keeps that comment in the built shell,
# so a bare `grep BRAND_TITLE` always matches. Only an unsubstituted
# ${BRAND_TITLE}/${BRAND_DESCRIPTION} is a real leak.
for tok in '${BRAND_TITLE}' '${BRAND_DESCRIPTION}'; do
echo "$body" | grep -qF "$tok" && { echo "::error::unrendered placeholder $tok leaked"; exit 1; } || true
done
- name: Assert hashed assets are cached immutably
run: |
asset=$(curl -fsS http://localhost:3000/admin | grep -oE '/assets/[^"]+\.js' | head -1)
test -n "$asset" || { echo "::error::no asset reference found in SPA shell"; exit 1; }
headers=$(curl -fsSI "http://localhost:3000${asset}")
echo "$headers" | grep -qi 'cache-control:.*immutable' || { echo "::error::asset served without immutable cache header"; echo "$headers"; exit 1; }
- name: Assert API and root respond
run: |
curl -fsS http://localhost:3000/api/public/settings > /dev/null
# `/` goes to handlePublicSiteRequest, which 302s to /admin/login while
# the public landing site is disabled — the state of a fresh install,
# which is exactly what this container is. Assert the redirect target
# rather than a 200, so the check still proves express.static's index
# option isn't shadowing the handler.
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/)
loc=$(curl -s -o /dev/null -w '%{redirect_url}' http://localhost:3000/)
[[ "$code" == "302" && "$loc" == *"/admin/login" ]] \
|| { echo "::error::/ returned $code (Location: ${loc:-none}); expected 302 -> /admin/login"; exit 1; }
- name: Assert every client route survives a direct hit
run: |
# nginx did `try_files $uri $uri/ /index.html`, so behind compose these
# always worked and nothing caught their absence here. /setup is the
# first URL a new install visits.
for r in /setup /customer /impressum /datenschutz /payment-check \
/quote/x /contract/x /invite/x /transfer/x /transfer-upload/x; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${r}")
[[ "$code" == "200" ]] || { echo "::error::${r} returned $code, expected the SPA shell"; exit 1; }
done
- name: Assert the catch-all did not swallow the API or the short-URL resolver
run: |
# The SPA catch-all is registered after the /api 404 handler, so an
# unknown API route must still answer JSON rather than the HTML shell.
body=$(curl -s "http://localhost:3000/api/nope")
grep -q '<div id="root">' <<< "$body" && { echo "::error::unknown /api route served the SPA shell"; exit 1; } || true
grep -q '"error"' <<< "$body" || { echo "::error::unknown /api route did not answer JSON: $body"; exit 1; }
# A typo'd short URL must still 404 rather than render the shell (#699).
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/s/nonexistent)
[[ "$code" == "404" ]] || { echo "::error::/s/<unknown> returned $code, expected 404"; exit 1; }
- name: Assert the SPA bundle is gzipped
run: |
asset=$(curl -fsS http://localhost:3000/admin | grep -oE '/assets/[^"]+\.js' | head -1)
# GET, not HEAD: the compression middleware skips bodyless responses,
# so a HEAD probe reports no Content-Encoding even when gzip is active.
enc=$(curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' "http://localhost:3000${asset}" | grep -i '^content-encoding:')
grep -qi gzip <<< "$enc" || { echo "::error::asset served uncompressed (compression middleware inactive?)"; exit 1; }
- name: Assert the one-volume layout and backup destinations
run: |
# #1042 asks for a single mountable root. Everything that must survive a
# container replacement lives under /data, and /backup — where migrations
# 029/030 seed the backup destinations — symlinks into it rather than
# dangling inside the container.
docker exec aio sh -c 'test -L /backup' || { echo "::error::/backup is not a symlink into the volume"; exit 1; }
for d in /data/db /data/storage /data/logs /data/backup/picpeak /data/backup/database; do
docker exec aio sh -c "test -d $d" || { echo "::error::$d missing from the volume layout"; exit 1; }
done
docker exec aio sh -c 'touch /backup/database/.w && rm /backup/database/.w' \
|| { echo "::error::/backup/database is not writable by the app user"; exit 1; }
- name: Assert the sqlite3 CLI the backup service shells out to
run: |
# DatabaseBackupService spawns `sqlite3` for .backup and integrity_check;
# the npm module does not ship the binary.
docker exec aio sqlite3 --version > /dev/null \
|| { echo "::error::sqlite3 CLI missing — database backups would fail with ENOENT"; exit 1; }
- name: Assert logs land on the volume
run: |
docker exec aio sh -c 'ls /data/logs/*.log > /dev/null 2>&1' \
|| { echo "::error::logs are not being written under /data (LOG_DIR ignored?)"; exit 1; }
- name: Assert backend static routes still 404 instead of the SPA shell
run: |
# /photos, /thumbnails, /uploads and /fonts are backend-owned mounts whose
# middleware calls next() on a miss. nginx gave them their own location
# blocks so try_files never applied; without an explicit exclusion the
# catch-all answers 200 text/html under an image or font URL.
for r in /photos/missing.jpg /thumbnails/missing.jpg /fonts/missing.woff2; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${r}")
[[ "$code" != "200" ]] || { echo "::error::${r} returned 200 — the SPA catch-all swallowed a backend 404"; exit 1; }
done
- name: Assert the image carries no runtime data from the build context
run: |
# Dockerfile.aio builds from the repo root; a checkout used to run
# PicPeak must never bake its database, photos, logs or secrets into a
# layer. /app/storage is deliberately a symlink into the volume, so it
# is checked by shape rather than by listing it — following the link
# would only find the empty tree the image creates at /data/storage.
for leak in '/app/data/*.db' '/app/logs/*' '/app/.env' '/app/*.db' '/app/*.sqlite*'; do
if docker exec aio sh -c "ls $leak > /dev/null 2>&1"; then
echo "::error::build context leaked $leak into the image"; exit 1
fi
done
docker exec aio sh -c 'test -L /app/storage' \
|| { echo "::error::/app/storage is a real directory — the build context leaked it in"; exit 1; }
test "$(docker exec aio sh -c 'readlink /app/storage')" = /data/storage \
|| { echo "::error::/app/storage does not point into the mounted volume"; exit 1; }
# The volume's photo tree must start empty on a fresh install.
found=$(docker exec aio sh -c 'find /data/storage/events -type f | head -1')
test -z "$found" || { echo "::error::build context leaked photos into /data/storage/events: $found"; exit 1; }
- name: Dump logs on failure
if: failure()
run: docker logs aio 2>&1 | tail -200
summary:
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
needs: [build-backend, merge-backend, build-frontend, merge-frontend, build-aio, merge-aio, smoke-aio]
if: always()
runs-on: ubuntu-latest
permissions:
@@ -612,6 +992,7 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
echo "AIO_IMAGE_NAME=${repo_lc}/aio" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
@@ -655,10 +1036,31 @@ jobs:
echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-aio.result }}" == "success" ]]; then
echo "✅ **AIO build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **AIO build (per-arch)**: ${{ needs.build-aio.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-aio.result }}" == "success" ]]; then
echo "✅ **AIO manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-aio.result }}" == "skipped" ]]; then
echo "️ **AIO manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **AIO manifest merge**: ${{ needs.merge-aio.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.smoke-aio.result }}" == "success" ]]; then
echo "✅ **AIO boot smoke**: SQLite boot + SPA + caching verified" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **AIO boot smoke**: ${{ needs.smoke-aio.result }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- All-in-one: \`${{ env.REGISTRY }}/${{ env.AIO_IMAGE_NAME }}\` (GHCR only — Docker Hub mirror pending)" >> $GITHUB_STEP_SUMMARY
if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY