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/.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-preview.yml b/.github/workflows/docker-preview.yml new file mode 100644 index 000000000..ef5005d82 --- /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 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 }} + 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 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 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..408c01fc1 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,106 @@ +# 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 | +| ``, `-` | 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: