Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 61 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,74 @@ 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 &rarr; Add stack &rarr; 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 <this-repo-url> && cd model-workshop-manager
cp .env.example .env # set SESSION_SECRET
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
Expand All @@ -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. |
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -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: .
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading