From 6eecda7c4b4f91e195fe2cdaafb37b6e79f7a5c9 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 6 Sep 2026 18:09:24 +0200 Subject: [PATCH 1/4] build: add an official runtime container image --- .dockerignore | 5 +++++ Dockerfile | 37 +++++++++++++++++++++++++++++++++++++ README.md | 2 +- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..c04ca1ebd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +node_modules +**/node_modules +**/test +**/tests diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..664119d70 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Official Indiekit runtime image. +# +# The whole workspace is installed, so every official package (endpoints, post +# types, presets, stores, syndicators) resolves by name from a mounted +# indiekit.config.js. Third-party plugins are not included. +# +# Run it with your configuration and content store mounted at /app, as your +# own user so files written to ./content belong to you: +# docker run -p 3000:3000 \ +# -v ./indiekit.config.js:/app/indiekit.config.js:ro \ +# -v ./content:/app/content \ +# --user "$(id -u):$(id -g)" \ +# -e PUBLICATION_URL -e MONGO_URL -e SECRET -e PASSWORD_SECRET \ +# ghcr.io/getindiekit/indiekit +ARG NODE_VERSION=24 +FROM node:${NODE_VERSION}-alpine + +WORKDIR /app +ENV NODE_ENV=production + +# Run as the unprivileged user the base image provides, so files written to a +# mounted content store are not owned by root. +RUN chown node:node /app +USER node + +# The workspace manifests live inside packages/ and helpers/, so the whole +# tree is copied before install; a source change therefore reinstalls +# dependencies. Acceptable for a release image. +COPY --chown=node:node package.json package-lock.json ./ +COPY --chown=node:node helpers ./helpers +COPY --chown=node:node packages ./packages +# --ignore-scripts: the root postinstall is husky, a development hook. Native +# modules (sharp) ship prebuilt binaries and need no install script. +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force + +EXPOSE 3000 +CMD ["node", "packages/indiekit/bin/cli.js", "serve"] diff --git a/README.md b/README.md index 2a89b1320..c71f601c0 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ A [MongoDB](https://www.mongodb.com) database is optional, but required for the ## Get started -Learn how to [set up an Indiekit server](docs/get-started.md) and view an [example server configuration](https://github.com/getindiekit/example-config). +Learn how to [set up an Indiekit server](docs/get-started.md) and view an [example server configuration](https://github.com/getindiekit/example-config). A container image is published on each release at `ghcr.io/getindiekit/indiekit`; see [docs/docker.md](docs/docker.md). ## Documentation website From 15b758a641b2865081a345576379c0efdf53a412 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 6 Sep 2026 18:09:24 +0200 Subject: [PATCH 2/4] ci: publish the container image on release, nightly and preview channels --- .github/workflows/docker-check.yml | 36 ++++++++++++++++ .github/workflows/docker-image.yml | 63 ++++++++++++++++++++++++++++ .github/workflows/docker-nightly.yml | 61 +++++++++++++++++++++++++++ .github/workflows/docker-preview.yml | 60 ++++++++++++++++++++++++++ .github/workflows/docker-release.yml | 59 ++++++++++++++++++++++++++ 5 files changed, 279 insertions(+) create mode 100644 .github/workflows/docker-check.yml create mode 100644 .github/workflows/docker-image.yml create mode 100644 .github/workflows/docker-nightly.yml create mode 100644 .github/workflows/docker-preview.yml create mode 100644 .github/workflows/docker-release.yml diff --git a/.github/workflows/docker-check.yml b/.github/workflows/docker-check.yml new file mode 100644 index 000000000..37ce49c04 --- /dev/null +++ b/.github/workflows/docker-check.yml @@ -0,0 +1,36 @@ +name: docker-check + +on: + pull_request: + paths: + - Dockerfile + - .dockerignore + - package-lock.json + - .github/workflows/docker-*.yml + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build + uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: indiekit:check + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run the image + run: docker run --rm --user 4242:4242 -e HOME=/nonexistent indiekit:check node packages/indiekit/bin/cli.js --version diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 000000000..b6e79c620 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,63 @@ +name: docker-image + +on: + workflow_call: + inputs: + ref: + description: Git ref to build + required: true + type: string + tags: + description: Newline-separated full image tags + required: true + type: string + version: + description: Value for org.opencontainers.image.version + required: true + type: string + +jobs: + build: + runs-on: ubuntu-latest + if: github.repository == 'getindiekit/indiekit' + concurrency: + group: docker-${{ inputs.version }} + cancel-in-progress: false + timeout-minutes: 60 + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ inputs.tags }} + provenance: false + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ inputs.version }} + org.opencontainers.image.licenses=MIT + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-nightly.yml b/.github/workflows/docker-nightly.yml new file mode 100644 index 000000000..c59ad138a --- /dev/null +++ b/.github/workflows/docker-nightly.yml @@ -0,0 +1,61 @@ +name: docker-nightly + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +jobs: + changed: + runs-on: ubuntu-latest + if: github.repository == 'getindiekit/indiekit' + permissions: + contents: read + packages: read + outputs: + build: ${{ steps.check.outputs.build }} + sha: ${{ steps.check.outputs.sha }} + steps: + - name: Checkout main + uses: actions/checkout@v7 + with: + ref: main + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compare main with the last nightly + id: check + run: | + set -uo pipefail + sha=$(git rev-parse HEAD) + short=${sha:0:7} + echo "sha=$short" >> "$GITHUB_OUTPUT" + # A nightly always tags its image main-. If the registry + # already has that tag, main has not moved since the last nightly. + # Any failure (no package yet, registry down) falls through to + # build=true: the check is an optimisation, never a gate. + if docker manifest inspect "ghcr.io/${{ github.repository }}:main-$short" >/dev/null 2>&1; then + echo "main-$short is already published; skipping" + echo "build=false" >> "$GITHUB_OUTPUT" + else + echo "build=true" >> "$GITHUB_OUTPUT" + fi + + image: + needs: changed + if: needs.changed.outputs.build == 'true' + uses: ./.github/workflows/docker-image.yml + with: + ref: main + tags: | + ghcr.io/${{ github.repository }}:nightly + ghcr.io/${{ github.repository }}:main-${{ needs.changed.outputs.sha }} + version: main-${{ needs.changed.outputs.sha }} + permissions: + contents: read + packages: write diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml new file mode 100644 index 000000000..fb6f44ac3 --- /dev/null +++ b/.github/workflows/docker-preview.yml @@ -0,0 +1,60 @@ +name: docker-preview + +on: + workflow_dispatch: + inputs: + ref: + description: Branch, tag or commit to build + required: true + type: string + default: main + channel: + description: Channel name for the moving tag, e.g. preview-v1 or rc + required: true + type: string + +jobs: + tags: + runs-on: ubuntu-latest + if: github.repository == 'getindiekit/indiekit' + permissions: + contents: read + outputs: + tags: ${{ steps.derive.outputs.tags }} + version: ${{ steps.derive.outputs.version }} + steps: + - name: Checkout ref + uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - name: Validate channel and derive tags + id: derive + env: + CHANNEL: ${{ inputs.channel }} + run: | + set -euo pipefail + [[ "$CHANNEL" =~ ^[a-z0-9][a-z0-9.-]*$ ]] || { echo "channel must match ^[a-z0-9][a-z0-9.-]*$"; exit 1; } + for reserved in latest nightly beta; do + [ "$CHANNEL" = "$reserved" ] && { echo "channel '$CHANNEL' is reserved for the release and nightly workflows"; exit 1; } + done + short=$(git rev-parse --short=7 HEAD) + image=ghcr.io/${{ github.repository }} + echo "version=$CHANNEL-$short" >> "$GITHUB_OUTPUT" + { + echo 'tags<> "$GITHUB_OUTPUT" + + image: + needs: tags + uses: ./.github/workflows/docker-image.yml + with: + ref: ${{ inputs.ref }} + tags: ${{ needs.tags.outputs.tags }} + version: ${{ needs.tags.outputs.version }} + permissions: + contents: read + packages: write diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 000000000..ada819011 --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -0,0 +1,59 @@ +name: docker-release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Release tag to build, e.g. v1.0.0-beta.29 + required: true + type: string + +jobs: + tags: + runs-on: ubuntu-latest + if: github.repository == 'getindiekit/indiekit' + permissions: + contents: read + outputs: + ref: ${{ steps.derive.outputs.ref }} + tags: ${{ steps.derive.outputs.tags }} + version: ${{ steps.derive.outputs.version }} + steps: + - name: Derive image tags from the release version + id: derive + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + set -euo pipefail + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]] || { echo "not a version tag: $TAG"; exit 1; } + image=ghcr.io/${{ github.repository }} + version="${TAG#v}" + echo "ref=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + { + echo 'tags< beta). + channel="${version#*-}"; channel="${channel%%.*}" + echo "$image:$channel" + else + echo "$image:latest" + echo "$image:${version%%.*}" + echo "$image:${version%.*}" + fi + echo 'EOF' + } >> "$GITHUB_OUTPUT" + + image: + needs: tags + uses: ./.github/workflows/docker-image.yml + with: + ref: ${{ needs.tags.outputs.ref }} + tags: ${{ needs.tags.outputs.tags }} + version: ${{ needs.tags.outputs.version }} + permissions: + contents: read + packages: write From e7c0a99da5e38d3d3ed83f5a863ac9ee3d5b82ec Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 6 Sep 2026 18:09:25 +0200 Subject: [PATCH 3/4] docs: run Indiekit in a container --- docs/.vitepress/config.js | 1 + docs/docker.md | 107 ++++++++++++++++++++++++++++++++++++++ docs/get-started.md | 2 + 3 files changed, 110 insertions(+) create mode 100644 docs/docker.md diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index b4ae37b9a..9130846b2 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -9,6 +9,7 @@ const sidebar = [ text: "Introduction", items: [ { text: "Get started", link: "/get-started" }, + { text: "Run in a container", link: "/docker" }, { text: "How Indiekit works", link: "/introduction" }, { text: "Core concepts", link: "/concepts" }, { text: "Sponsors", link: "/sponsors" }, diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 000000000..49f7e07dd --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,107 @@ +# Run Indiekit in a container + +An image with every official plugin is published at `ghcr.io/getindiekit/indiekit`. You supply a configuration file, a content store and a MongoDB database; the image supplies everything else. + +## Quick start with Docker Compose + +Create `indiekit.config.js` (see [Configuration](/configuration/)) and a `compose.yml`: + +```yaml +services: + indiekit: + image: ghcr.io/getindiekit/indiekit:beta + restart: unless-stopped + user: "1000:1000" + ports: + - "3000:3000" + volumes: + - ./indiekit.config.js:/app/indiekit.config.js:ro + - ./content:/app/content + environment: + PUBLICATION_URL: http://localhost:3000 + MONGO_URL: mongodb://mongo:27017/indiekit + SECRET: ${SECRET} + PASSWORD_SECRET: ${PASSWORD_SECRET} + depends_on: + mongo: + condition: service_healthy + mongo: + image: mongo:8 + restart: unless-stopped + volumes: + - mongo:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 +volumes: + mongo: +``` + +Replace `1000:1000` with your own user and group ids (`id -u` and `id -g`), so files written to `./content` belong to you. + +Create the store directory yourself, so it is owned by you: + +```sh +mkdir content +``` + +Write a `.env` file beside `compose.yml` with your secrets: + +```sh +printf "SECRET='…'\nPASSWORD_SECRET='…'\n" > .env +``` + +`SECRET` is any long random string; `PASSWORD_SECRET` comes from the "Set your password" section below. + +Then: + +```sh +docker compose up -d +``` + +Indiekit listens on port 3000. Put a reverse proxy with HTTPS in front of it for a public site. + +## What the image expects + +Run the container as your own uid:gid so the mounted store is writable; the default `node` user (1000) only works when your uid is 1000. + +| Path or variable | Purpose | +|---|---| +| `/app/indiekit.config.js` | Your configuration. Mounted read-only. | +| `/app/content` | The directory for `@indiekit/store-file-system`, if you use it. | +| `PUBLICATION_URL` | Your site’s URL. | +| `MONGO_URL` | MongoDB connection string. | +| `SECRET`, `PASSWORD_SECRET` | See [Get started](/get-started). | +| `PORT` | Optional, defaults to 3000. | + +Any plug-ins you configure may need their own secrets; pass them the same way. + +Any official plugin named in your configuration is already installed. Third-party plugins are not included in the image; build your own image on top of this one to add them. + +> [!WARNING] +> `PUBLICATION_URL` must resolve from inside the container: signing in makes the server fetch its own URL to complete IndieAuth. On a laptop that means the published host port must equal the container port (`3000:3000` with `PUBLICATION_URL=http://localhost:3000`); behind a reverse proxy, the public URL must be reachable from the container. + +## Set your password + +1. Start the stack without `PASSWORD_SECRET` set. +2. Visit `/auth/new-password` at your `PUBLICATION_URL`. +3. Enter the password you want to use and copy the generated value. +4. Set `PASSWORD_SECRET` to that value and restart the stack. + +See [Get started](/get-started) for the `$` quoting rules that apply to this value in `compose.yml`. + +## Tags + +The first image appears with the first release after this lands; until then no tag exists. + +| Tag | Built from | When | +|---|---|---| +| `1.0.0-beta.29` | The release tag | Each GitHub release | +| `beta` | The newest prerelease | Each GitHub release while Indiekit is in beta | +| `latest`, `1`, `1.0` | The newest stable release | From the first stable release onward | +| `nightly`, `main-` | `main` | Every night `main` has changed | +| ``, `-` | Any ref, on request | When a maintainer publishes a preview | + +Images are built for `linux/amd64` and `linux/arm64`. diff --git a/docs/get-started.md b/docs/get-started.md index 98ff0261d..b5c9deeff 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -55,6 +55,8 @@ where `directory_name` is the name of the directory within which you want to sav This is the fastest way to set up a new Indiekit server from scratch. +Prefer containers? See [Run Indiekit in a container](/docker). + > Alternatively, you can clone this [example configuration](https://github.com/getindiekit/example-config) repository on GitHub and manually edit the values in the configuration file. The wizard will walk you through every step of configuring a new server, and will ask you questions about: From a7ca70a8ebbc6cfaca03f303f84f094096857af2 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Mon, 12 Oct 2026 08:12:18 +0200 Subject: [PATCH 4/4] build: drop the nightly container image channel Release and manually published previews cover the need; a daily multi-arch build spends Actions minutes and leaves a `main-` version in the registry per run for nobody in particular. `nightly` also stops being a reserved channel name in the preview workflow, since no workflow claims it any more. --- .github/workflows/docker-nightly.yml | 61 ---------------------------- .github/workflows/docker-preview.yml | 4 +- docs/docker.md | 1 - 3 files changed, 2 insertions(+), 64 deletions(-) delete mode 100644 .github/workflows/docker-nightly.yml diff --git a/.github/workflows/docker-nightly.yml b/.github/workflows/docker-nightly.yml deleted file mode 100644 index c59ad138a..000000000 --- a/.github/workflows/docker-nightly.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: docker-nightly - -on: - schedule: - - cron: "17 3 * * *" - workflow_dispatch: - -jobs: - changed: - runs-on: ubuntu-latest - if: github.repository == 'getindiekit/indiekit' - permissions: - contents: read - packages: read - outputs: - build: ${{ steps.check.outputs.build }} - sha: ${{ steps.check.outputs.sha }} - steps: - - name: Checkout main - uses: actions/checkout@v7 - with: - ref: main - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Compare main with the last nightly - id: check - run: | - set -uo pipefail - sha=$(git rev-parse HEAD) - short=${sha:0:7} - echo "sha=$short" >> "$GITHUB_OUTPUT" - # A nightly always tags its image main-. If the registry - # already has that tag, main has not moved since the last nightly. - # Any failure (no package yet, registry down) falls through to - # build=true: the check is an optimisation, never a gate. - if docker manifest inspect "ghcr.io/${{ github.repository }}:main-$short" >/dev/null 2>&1; then - echo "main-$short is already published; skipping" - echo "build=false" >> "$GITHUB_OUTPUT" - else - echo "build=true" >> "$GITHUB_OUTPUT" - fi - - image: - needs: changed - if: needs.changed.outputs.build == 'true' - uses: ./.github/workflows/docker-image.yml - with: - ref: main - tags: | - ghcr.io/${{ github.repository }}:nightly - ghcr.io/${{ github.repository }}:main-${{ needs.changed.outputs.sha }} - version: main-${{ needs.changed.outputs.sha }} - permissions: - contents: read - packages: write diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index fb6f44ac3..ef5005d82 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -35,8 +35,8 @@ jobs: run: | set -euo pipefail [[ "$CHANNEL" =~ ^[a-z0-9][a-z0-9.-]*$ ]] || { echo "channel must match ^[a-z0-9][a-z0-9.-]*$"; exit 1; } - for reserved in latest nightly beta; do - [ "$CHANNEL" = "$reserved" ] && { echo "channel '$CHANNEL' is reserved for the release and nightly workflows"; exit 1; } + for reserved in latest beta; do + [ "$CHANNEL" = "$reserved" ] && { echo "channel '$CHANNEL' is reserved for the release workflow"; exit 1; } done short=$(git rev-parse --short=7 HEAD) image=ghcr.io/${{ github.repository }} diff --git a/docs/docker.md b/docs/docker.md index 49f7e07dd..408c01fc1 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -101,7 +101,6 @@ The first image appears with the first release after this lands; until then no t | `1.0.0-beta.29` | The release tag | Each GitHub release | | `beta` | The newest prerelease | Each GitHub release while Indiekit is in beta | | `latest`, `1`, `1.0` | The newest stable release | From the first stable release onward | -| `nightly`, `main-` | `main` | Every night `main` has changed | | ``, `-` | Any ref, on request | When a maintainer publishes a preview | Images are built for `linux/amd64` and `linux/arm64`.