diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..2411e30 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,187 @@ +name: Docker publish + +on: + push: + branches: [main] + tags: ["v*.*.*"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Publishing to a public registry means strangers pull whatever lands on + # main. CI today only runs on pull_request, so a direct push to main would + # otherwise publish untested code. + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm test + + build: + name: Build ${{ matrix.platform }} + needs: test + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + + # Derived, not hardcoded, so a fork publishes to its own namespace + # instead of failing against this one. Lowercased because GITHUB_ + # REPOSITORY keeps GitHub's display casing ("Lucifix/...") and OCI + # image references reject uppercase. + - name: Resolve image name + run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + + - name: Normalize platform name + run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV" + env: + PLATFORM: ${{ matrix.platform }} + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Labels only here; tags are applied in the merge job. The OCI + # source label this emits is what links the GHCR package back to the + # repo and renders the README on the package page. + - name: Docker labels + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.IMAGE }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + provenance: false + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge manifests & tag + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Resolve image name + run: | + echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + name="${GITHUB_REPOSITORY##*/}" + echo "REPO_NAME=${name,,}" >> "$GITHUB_ENV" + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # main -> latest + sha-xxxxxxx; vX.Y.Z tag -> X.Y.Z, X.Y, X. + # is_default_branch is false on tag pushes, so a release never + # silently moves latest. + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=short,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - name: Create multi-arch manifest + working-directory: /tmp/digests + run: | + # Word splitting is deliberate here: the first substitution expands + # to "-t tag -t tag ...", the second to one ref per digest file. + # shellcheck disable=SC2046 + docker buildx imagetools create \ + $(jq -cr '.target."docker-metadata-action".tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + + # The secrets context is not available in `if:` expressions, so + # presence has to be probed in a shell step and passed on as an output. + - name: Check for Docker Hub credentials + id: hub + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + if [ -n "$DOCKERHUB_USERNAME" ] && [ -n "$DOCKERHUB_TOKEN" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::notice::Docker Hub secrets not set — published to GHCR only." + fi + + - name: Log in to Docker Hub + if: steps.hub.outputs.enabled == 'true' + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Copies the finished manifest list across registries — same digests, + # no second build. + - name: Mirror tags to Docker Hub + if: steps.hub.outputs.enabled == 'true' + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + echo "$TAGS" | while read -r tag; do + [ -n "$tag" ] || continue + docker buildx imagetools create \ + -t "docker.io/${DOCKERHUB_USERNAME}/${REPO_NAME}:${tag##*:}" "$tag" + done diff --git a/Dockerfile b/Dockerfile index e8d5caa..e98dcc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,16 @@ COPY --from=build /app/build ./build COPY --from=build /app/app/db/migrations ./app/db/migrations COPY server.js ./server.js +# The app's own fallbacks for these are *relative* ("./data/database/workshop.db", +# "./backups"), which resolve under WORKDIR and would quietly land outside the +# mounted volume — data written to a layer that vanishes on the next pull. +# Baking them in means the image is correct standalone, so a bare `docker run` +# or a minimal compose snippet doesn't have to know any of this. +ENV DATABASE_URL=/data/database/workshop.db \ + DATA_DIR=/data \ + UPLOAD_DIR=/data/uploads \ + BACKUP_DIR=/backups + # A named volume inherits the ownership of the image path it mounts over, so # these have to exist and belong to `node` *before* the volume is created — # otherwise Docker creates them root-owned and the unprivileged process below diff --git a/README.md b/README.md index 90ef8b3..52a27a2 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,62 @@ phone or desktop, running entirely on your own server. ## Quick start (Docker) +Two ways to run it: pull the published image (no clone needed — this is the one to use for a +Portainer stack), or build from source. + +### Option A: pull the published image + +Multi-arch (`linux/amd64` + `linux/arm64` — Raspberry Pi and ARM NAS boxes included): + +- GHCR: `ghcr.io/lucifix/model-workshop-manager` +- Docker Hub: `docker.io/lucifix/model-workshop-manager` + +Paste this straight into Portainer (Stacks → Add stack → Web editor) or save it as +`docker-compose.yml` anywhere. Set `SESSION_SECRET` in Portainer's environment-variables box, or +in a `.env` file next to the compose file: + +```yaml +services: + app: + image: ghcr.io/lucifix/model-workshop-manager:latest + container_name: model-workshop-manager + restart: unless-stopped + ports: + - 8080:3000 + environment: + # Required — the app won't start without it. + # Generate one with: openssl rand -base64 32 + SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET in your stack environment or .env} + # Set true once you're serving this over HTTPS, or login will silently fail. + SESSION_COOKIE_SECURE: "false" + # Number of reverse proxies in front of the app, if any. Omit if none. + # TRUST_PROXY: 1 + volumes: + - workshop-data:/data + - workshop-backups:/backups + +volumes: + workshop-data: + workshop-backups: +``` + +Everything else (database path, uploads, port inside the container) is baked into the image. +For the full file — including the optional nightly-backup sidecar and bind-mount options — use +[`docker-compose.yml`](docker-compose.yml) from this repo: + +```bash +mkdir model-workshop-manager && cd model-workshop-manager +curl -O https://raw.githubusercontent.com/Lucifix/model-workshop-manager/main/docker-compose.yml +curl -O https://raw.githubusercontent.com/Lucifix/model-workshop-manager/main/.env.example +cp .env.example .env # set SESSION_SECRET +docker compose pull +docker compose up -d +``` + +**Updating:** `docker compose pull && docker compose up -d` + +### Option B: build from source + ```bash git clone && cd model-workshop-manager cp .env.example .env # set SESSION_SECRET @@ -37,10 +93,11 @@ docker compose up -d --build docker compose exec app npm run db:seed # optional: sample data (migrations run automatically on boot) ``` -Open `http://localhost:8080` (or whatever `HOST_PORT` you set) — first visit shows a one-time -setup screen to create your username/password, then you're logged in. - **Updating:** `git pull && docker compose up -d --build` + +Both options open `http://localhost:8080` (or whatever `HOST_PORT` you set) — first visit shows a +one-time setup screen to create your username/password, then you're logged in. + **Stopping:** `docker compose down` (your data lives in the `workshop-data` volume, untouched) > **Before you expose this anywhere:** it's a single-user app meant for your LAN or a VPN @@ -57,6 +114,7 @@ All set in `.env` (copied from `.env.example`), read by `docker-compose.yml`. | `TRUST_PROXY` | No | _(off)_ | Set to the number of reverse proxies in front of the app (usually `1`) so the login rate limiter sees real client IPs. `true` is refused — it's spoofable. | | `HOST_PORT` | No | `8080` | Host port the app is served on. | | `APP_PORT` | No | `3000` | Port the app listens on inside the container. Rarely needs changing. | +| `APP_IMAGE_TAG` | No | `latest` | Which published image tag to run (`latest`, or a pinned version like `1.2.3`). Ignored when building from source. | | `APP_UID` / `APP_GID` | No | `1000` | uid:gid the container runs as. Set these to match the owner of `WORKSHOP_DATA_DIR`/`BACKUP_DIR` if bind-mounting. | | `WORKSHOP_DATA_DIR` | No | _(named volume)_ | Host path for the database + uploaded photos, instead of the `workshop-data` Docker volume — e.g. a NAS mount. | | `BACKUP_DIR` | No | `./backups` | Host path where backups (in-app, manual script, and the nightly job) are written. | diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..1c38326 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,8 @@ +# Auto-loaded by Compose only when it sits next to docker-compose.yml — i.e. +# in a repo clone. Builds the image from source instead of pulling it, so +# `docker compose up -d --build` works exactly as it did before. A Portainer +# stack or a curl'd docker-compose.yml never sees this file, and pulls. +services: + app: + build: + context: . diff --git a/docker-compose.yml b/docker-compose.yml index 6186685..b2d9f6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,9 @@ services: app: - build: - context: . + # Published multi-arch image (linux/amd64 + linux/arm64). In a repo + # clone, docker-compose.override.yml sits next to this file and builds + # this same tag from source instead. + image: ghcr.io/lucifix/model-workshop-manager:${APP_IMAGE_TAG:-latest} restart: unless-stopped # Runs unprivileged. Defaults to the image's own `node` user (uid 1000), # which owns /data and /backups inside the image — so a named volume just