From d2c270b7f95b05f216e52c7fc4a802ecaccfc769 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Wed, 29 Apr 2026 02:00:10 +0200 Subject: [PATCH 1/5] chore(compose): rename docker-compose.* to compose.* (Compose v2 spec) --- docker-compose.prod.yml => compose.prod.yaml | 8 +++++--- docker-compose.yml => compose.yaml | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) rename docker-compose.prod.yml => compose.prod.yaml (81%) rename docker-compose.yml => compose.yaml (95%) diff --git a/docker-compose.prod.yml b/compose.prod.yaml similarity index 81% rename from docker-compose.prod.yml rename to compose.prod.yaml index 0b0968d..1dfa74c 100644 --- a/docker-compose.prod.yml +++ b/compose.prod.yaml @@ -1,10 +1,12 @@ -# docker-compose.prod.yml — production overrides. +# compose.prod.yaml — production overrides (generic, non-netcup). # # Use: -# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# docker compose -f compose.yaml -f compose.prod.yaml up -d +# +# For the netcup deployment, use `compose.netcup.yaml` instead. # # Inputs (env or .env file): -# LIBRENOTES_IMAGE registry image, e.g. registry.librete.ch/librenotes:1.2.3 +# LIBRENOTES_IMAGE registry image, e.g. git.librete.ch/public/librenotes:v0.1.0 # LIBRENOTES_BASE_URL public origin, e.g. https://librenot.es # LIBRENOTES_JWT_SECRET secrets manager value, NOT committed # LIBRENOTES_SMTP_HOST real SMTP host diff --git a/docker-compose.yml b/compose.yaml similarity index 95% rename from docker-compose.yml rename to compose.yaml index 0cda183..6353ada 100644 --- a/docker-compose.yml +++ b/compose.yaml @@ -1,4 +1,4 @@ -# docker-compose.yml — local development. +# compose.yaml — local development. # # Brings up librenotes on http://localhost:8080 with the magic-link # mailer logging links to stdout (no SMTP needed). Data lives in -- 2.36.6 From 856a480f5399ff4019e586e22217ed6fb6856ae4 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Wed, 29 Apr 2026 02:00:15 +0200 Subject: [PATCH 2/5] feat(deploy): pull published image on netcup instead of building compose.netcup.yaml now references ${LIBRENOTES_IMAGE} with pull_policy: always and resets the base build context. .env.netcup.example documents the new LIBRENOTES_IMAGE key (default git.librete.ch/public/librenotes:main, pin to immutable tag for prod). Rollback: edit .env LIBRENOTES_IMAGE + up -d. --- .env.netcup.example | 7 ++++++- compose.netcup.yaml | 21 +++++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.env.netcup.example b/.env.netcup.example index b32203b..f2712e4 100644 --- a/.env.netcup.example +++ b/.env.netcup.example @@ -1,5 +1,10 @@ # Server-side .env for netcup deploy. Copy to /srv/librenotes/.env on the host. -# Used by: docker compose -f docker-compose.yml -f compose.netcup.yaml up -d --build +# Used by: docker compose -f compose.yaml -f compose.netcup.yaml up -d + +# Image to pull. Main pushes update :main; tag pushes pin to immutable +# tags such as :v0.1.0. The deploy workflow rewrites this line on tag +# pushes; rollback is `perl -i -pe 's|...|=...:vX.Y.Z|' .env && up -d`. +LIBRENOTES_IMAGE=git.librete.ch/public/librenotes:main # Public origin (caddy reverse-proxies to librenotes:8080) LIBRENOTES_BASE_URL=https://ln.cloud.librete.ch diff --git a/compose.netcup.yaml b/compose.netcup.yaml index 5449155..4c050e9 100644 --- a/compose.netcup.yaml +++ b/compose.netcup.yaml @@ -1,15 +1,19 @@ # compose.netcup.yaml — overlay for the netcup VPS. # # Use: -# docker compose -f docker-compose.yml -f compose.netcup.yaml up -d --build +# docker compose -f compose.yaml -f compose.netcup.yaml pull +# docker compose -f compose.yaml -f compose.netcup.yaml up -d # -# Differences from base docker-compose.yml: +# Differences from base compose.yaml: +# - Pulls a published image (no build context on the host) # - No host port binding (caddy edge handles ingress) # - Joins external `edge` network so caddy reaches it as `librenotes:8080` -# - Pulls config from .env (LIBRENOTES_BASE_URL, JWT_SECRET, SMTP_*) +# - Pulls config from .env (LIBRENOTES_BASE_URL, JWT_SECRET, SMTP_*, IMAGE) # - Uses bind-mounted /data + /var/lib/librenotes so backups can rsync host paths # # Inputs (env or .env file on netcup): +# LIBRENOTES_IMAGE e.g. git.librete.ch/public/librenotes:main +# or pinned tag git.librete.ch/public/librenotes:v0.1.0 # LIBRENOTES_BASE_URL https://ln.cloud.librete.ch # LIBRENOTES_JWT_SECRET `openssl rand -base64 48` # LIBRENOTES_SMTP_HOST SMTP relay host @@ -17,14 +21,15 @@ # LIBRENOTES_SMTP_USER SMTP user # LIBRENOTES_SMTP_PASS SMTP password # LIBRENOTES_SMTP_FROM no-reply@librete.ch (envelope sender) +# +# Rollback: edit LIBRENOTES_IMAGE in /srv/librenotes/.env to a prior +# tag, then `docker compose ... pull && docker compose ... up -d`. services: librenotes: - build: - context: . - args: - VERSION: netcup - image: librenotes:netcup + build: !reset null + image: ${LIBRENOTES_IMAGE} + pull_policy: always restart: always ports: !reset [] environment: -- 2.36.6 From 37842b6294ea9ec3b723e5e4b9595e88457f72f7 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Wed, 29 Apr 2026 02:00:23 +0200 Subject: [PATCH 3/5] ci(deploy): fix registry path, compose refs, Gitea Actions compat - Image base is now ${REGISTRY}/public/librenotes (matches Gitea owner/repo). - Remote step writes LIBRENOTES_IMAGE on tag pushes via perl, then pulls and restarts using the new compose.yaml + compose.netcup.yaml stack files. - Both jobs run inside catthehacker/ubuntu:runner-latest; the default node:20-bookworm runner image lacks make + docker. The build job bind-mounts /var/run/docker.sock for build-push-action; the runner config must whitelist that path under valid_volumes. --- .gitea/workflows/ci.yml | 4 ++ .gitea/workflows/deploy.yml | 76 +++++++++++++++++++++++++++---------- 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a85a9bc..a3f0894 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -9,6 +9,10 @@ on: jobs: ci: runs-on: ubuntu-latest + # Pin image: needs make (not in node:20-bookworm). runner-latest + # bundles make, git, curl + node for setup-go. + container: + image: catthehacker/ubuntu:runner-latest timeout-minutes: 5 steps: - name: Checkout diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c2655de..a5c7793 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,24 +6,39 @@ on: tags: ["v*"] # Required repository secrets: -# REGISTRY registry hostname, e.g. registry.librete.ch -# REGISTRY_USER robot account -# REGISTRY_PASS robot token -# DEPLOY_HOST deployment SSH target, e.g. root@librenot.es +# REGISTRY registry hostname, e.g. git.librete.ch +# REGISTRY_USER robot account or PAT username +# REGISTRY_PASS robot/PAT token with package:write +# DEPLOY_HOST deployment SSH target, e.g. root@netcup # DEPLOY_KEY private SSH key (PEM, no passphrase) -# DEPLOY_PATH remote directory containing the compose stack +# DEPLOY_PATH remote stack directory, e.g. /srv/librenotes # HEALTH_URL public URL to verify post-deploy, e.g. -# https://librenot.es/healthz +# https://ln.cloud.librete.ch/healthz # -# Tag pushes deploy the tag (vX.Y.Z); main-branch pushes deploy -# the rolling :main image. Set image to immutable tag so rollback -# is just `docker compose -f ... up -d` with the previous tag. +# Required repository variable: +# DEPLOY_ENABLED set to "true" to enable the workflow +# +# Image path: ${REGISTRY}/public/librenotes (matches Gitea owner/repo). +# Main pushes publish :main and :. Tag pushes publish : and +# :latest, then pin LIBRENOTES_IMAGE on the host to the immutable tag +# so rollback is just `perl -i -pe 's|^LIBRENOTES_IMAGE=.*|...=...:vX.Y.Z|' .env` +# followed by `docker compose ... up -d`. jobs: build: runs-on: ubuntu-latest + # Gitea Actions: pin image so docker CLI is present and mount the + # host docker socket so build-push-action can push to the registry. + # The runner declares /var/run/docker.sock in valid_volumes. + container: + image: catthehacker/ubuntu:runner-latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock timeout-minutes: 15 if: ${{ vars.DEPLOY_ENABLED == 'true' }} + outputs: + image_ref: ${{ steps.tags.outputs.image_ref }} + is_tag: ${{ steps.tags.outputs.is_tag }} steps: - uses: actions/checkout@v4 @@ -39,14 +54,19 @@ jobs: - name: Compute tags id: tags run: | - BASE="${{ secrets.REGISTRY }}/librenotes" + BASE="${{ secrets.REGISTRY }}/public/librenotes" if [[ "${GITHUB_REF}" == refs/tags/* ]]; then TAG="${GITHUB_REF##refs/tags/}" echo "tags=${BASE}:${TAG},${BASE}:latest" >> "$GITHUB_OUTPUT" echo "version=${TAG}" >> "$GITHUB_OUTPUT" + echo "image_ref=${BASE}:${TAG}" >> "$GITHUB_OUTPUT" + echo "is_tag=true" >> "$GITHUB_OUTPUT" else - echo "tags=${BASE}:main,${BASE}:${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + SHA7="${GITHUB_SHA::7}" + echo "tags=${BASE}:main,${BASE}:${SHA7}" >> "$GITHUB_OUTPUT" + echo "version=${SHA7}" >> "$GITHUB_OUTPUT" + echo "image_ref=${BASE}:main" >> "$GITHUB_OUTPUT" + echo "is_tag=false" >> "$GITHUB_OUTPUT" fi - uses: docker/build-push-action@v6 @@ -60,34 +80,48 @@ jobs: deploy: runs-on: ubuntu-latest + # Same image as build — bundles ssh, perl, curl. No docker needed. + container: + image: catthehacker/ubuntu:runner-latest needs: build timeout-minutes: 10 if: ${{ vars.DEPLOY_ENABLED == 'true' }} steps: - name: Configure SSH + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} run: | mkdir -p ~/.ssh - echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_deploy + printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_deploy chmod 600 ~/.ssh/id_deploy - ssh-keyscan -H "${{ secrets.DEPLOY_HOST#*@ }}" >> ~/.ssh/known_hosts || true + ssh-keyscan -H "${DEPLOY_HOST#*@}" >> ~/.ssh/known_hosts 2>/dev/null || true - name: Pull and restart on deploy host env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + IMAGE_REF: ${{ needs.build.outputs.image_ref }} + IS_TAG: ${{ needs.build.outputs.is_tag }} run: | - ssh -i ~/.ssh/id_deploy "$DEPLOY_HOST" \ - "cd $DEPLOY_PATH && \ - docker compose -f docker-compose.yml -f docker-compose.prod.yml pull && \ - docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans" + REMOTE_CMD='cd "$DEPLOY_PATH" || exit 1 + if [ "$IS_TAG" = "true" ]; then + perl -i -pe "s|^LIBRENOTES_IMAGE=.*|LIBRENOTES_IMAGE=$IMAGE_REF|" .env + grep -q "^LIBRENOTES_IMAGE=" .env || echo "LIBRENOTES_IMAGE=$IMAGE_REF" >> .env + fi + docker compose -f compose.yaml -f compose.netcup.yaml pull + docker compose -f compose.yaml -f compose.netcup.yaml up -d --remove-orphans' + ssh -i ~/.ssh/id_deploy \ + -o StrictHostKeyChecking=accept-new \ + "$DEPLOY_HOST" \ + "DEPLOY_PATH='$DEPLOY_PATH' IMAGE_REF='$IMAGE_REF' IS_TAG='$IS_TAG' bash -s" <<< "$REMOTE_CMD" - name: Verify health env: HEALTH_URL: ${{ secrets.HEALTH_URL }} run: | - # Give the new container ~30s to come up, then poll for - # a 200 from /healthz. Failure aborts the workflow which - # is the alert. + # Give the new container ~60s to come up, then poll for + # 200 from /healthz. Failure aborts the workflow. for i in $(seq 1 12); do if curl -fsS "$HEALTH_URL" >/dev/null; then echo "deploy verified" -- 2.36.6 From c261a23da8d831f62ab4d7345baf39d763d1c018 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Wed, 29 Apr 2026 02:00:29 +0200 Subject: [PATCH 4/5] docs(ops): document registry secrets, rollback, Gitea runner image operations.md lists the full secret/variable matrix the deploy workflow expects, the new compose.yaml + compose.netcup.yaml invocation, and a note on why both workflow jobs pin catthehacker/ubuntu:runner-latest. self-hosting.md updates the example LIBRENOTES_IMAGE value to the Gitea Packages path and switches the quick-start to compose.netcup.yaml. --- docs/operations.md | 52 +++++++++++++++++++++++++++++++------------- docs/self-hosting.md | 16 +++++++++----- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 996a4a3..b73b85a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -21,26 +21,32 @@ The workflow expects these secrets and variables on the repo: | Name | Type | Purpose | | ---- | ---- | ------- | -| `REGISTRY` | secret | hostname of the OCI registry | -| `REGISTRY_USER` | secret | robot account | -| `REGISTRY_PASS` | secret | robot token | -| `DEPLOY_HOST` | secret | `user@host` SSH target | -| `DEPLOY_KEY` | secret | passphrase-less private key | -| `DEPLOY_PATH` | secret | absolute path on host with `docker-compose.*.yml` | -| `HEALTH_URL` | secret | e.g. `https://librenot.es/healthz` | +| `REGISTRY` | secret | registry hostname, e.g. `git.librete.ch` | +| `REGISTRY_USER` | secret | robot account or PAT username with `package:write` | +| `REGISTRY_PASS` | secret | robot/PAT token | +| `DEPLOY_HOST` | secret | `user@host` SSH target, e.g. `root@netcup` | +| `DEPLOY_KEY` | secret | passphrase-less private key (PEM) | +| `DEPLOY_PATH` | secret | absolute path on host with the compose stack, e.g. `/srv/librenotes` | +| `HEALTH_URL` | secret | e.g. `https://ln.cloud.librete.ch/healthz` | | `DEPLOY_ENABLED` | variable | `true` to enable the workflow | +The image is pushed to `${REGISTRY}/public/librenotes`; main pushes +publish `:main` and `:`, tag pushes publish `:vX.Y.Z` and +`:latest`. + ### Production compose stack -On the deployment host, place `docker-compose.yml` and -`docker-compose.prod.yml` from this repo at `$DEPLOY_PATH`, -together with an `.env` file containing the runtime configuration -(JWT secret, SMTP credentials, public base URL, image tag). +On the deployment host, place `compose.yaml` and +`compose.netcup.yaml` from this repo at `$DEPLOY_PATH`, together +with an `.env` file containing `LIBRENOTES_IMAGE` plus the runtime +configuration (JWT secret, SMTP credentials, public base URL). +See `.env.netcup.example` for the full key list. Bring it up with: ```sh -docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +docker compose -f compose.yaml -f compose.netcup.yaml pull +docker compose -f compose.yaml -f compose.netcup.yaml up -d ``` ### Rollback @@ -52,11 +58,27 @@ on health-check failure — failed health alerts the operator via the workflow itself, who can then redeploy the prior tag manually. ```sh -# Example: roll back to v0.1.2 -sed -i 's/^LIBRENOTES_IMAGE=.*/LIBRENOTES_IMAGE=registry.librete.ch\/librenotes:v0.1.2/' .env -docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# Example: roll back to v0.1.0 +perl -i -pe 's|^LIBRENOTES_IMAGE=.*|LIBRENOTES_IMAGE=git.librete.ch/public/librenotes:v0.1.0|' .env +docker compose -f compose.yaml -f compose.netcup.yaml pull +docker compose -f compose.yaml -f compose.netcup.yaml up -d ``` +### Gitea Actions runner + +Workflows run on the netcup `act_runner` (see `runner/` stack in +the netcup umbrella). Both jobs declare `container: catthehacker/ubuntu:runner-latest` +because: + +- The default runner label image (`node:20-bookworm`) lacks `make` + and `docker`, both required by the workflows. +- The `runner-latest` image bundles `make`, `git`, `curl`, `ssh`, + `node`, plus a docker CLI. + +The runner config (`runner/config.yaml`) declares +`/var/run/docker.sock` as a `valid_volume` so the build job can +mount the host socket and push images via `docker/build-push-action`. + ## Backups `scripts/backup.sh` is a self-contained backup driver suitable for diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 434b682..720c42d 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -23,10 +23,16 @@ Docker Compose. For day-2 ops (deploys, backups) see ```sh git clone https://git.librete.ch/public/librenotes cd librenotes -cp .env.example .env # edit values, see below -docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +cp .env.netcup.example .env # edit values, see below +docker compose -f compose.yaml -f compose.netcup.yaml pull +docker compose -f compose.yaml -f compose.netcup.yaml up -d ``` +The `compose.netcup.yaml` overlay is the production-style overlay +shipped in this repo; it pulls a published image, drops host port +binding (assumes a fronting reverse proxy), and joins an external +`edge` docker network. Adapt to your topology if needed. + Visit `https://your-domain/` and sign in. ## Environment configuration @@ -45,7 +51,7 @@ Place them in an `.env` file next to the Compose stack. | `LIBRENOTES_SMTP_FROM` | yes | Envelope sender, e.g. `no-reply@notes.example.com`. | | `LIBRENOTES_DATA_DIR` | no | Default `/data` inside the container. | | `LIBRENOTES_DB` | no | Default `/var/lib/librenotes/librenotes.db`. | -| `LIBRENOTES_IMAGE` | yes (prod) | Image tag to pull, e.g. `registry.librete.ch/librenotes:v0.1.0`. | +| `LIBRENOTES_IMAGE` | yes (prod) | Image tag to pull, e.g. `git.librete.ch/public/librenotes:v0.1.0`. | Generate a JWT secret: @@ -109,8 +115,8 @@ to use `curl`/`wget`). Pull the new image and restart: ```sh -docker compose -f docker-compose.yml -f docker-compose.prod.yml pull -docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +docker compose -f compose.yaml -f compose.netcup.yaml pull +docker compose -f compose.yaml -f compose.netcup.yaml up -d ``` To pin a specific version, set `LIBRENOTES_IMAGE` to that tag in -- 2.36.6 From 3d3fc41f7338bc3d3683a968bad89f59faac1908 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Wed, 29 Apr 2026 11:46:21 +0200 Subject: [PATCH 5/5] ci(deploy): always pin LIBRENOTES_IMAGE and git pull on remote First deploy now works without manual .env priming: the remote step unconditionally rewrites LIBRENOTES_IMAGE, then runs git pull --ff-only so the host picks up the renamed compose.* files before pull/up. --- .gitea/workflows/deploy.yml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a5c7793..a8074e7 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -38,7 +38,6 @@ jobs: if: ${{ vars.DEPLOY_ENABLED == 'true' }} outputs: image_ref: ${{ steps.tags.outputs.image_ref }} - is_tag: ${{ steps.tags.outputs.is_tag }} steps: - uses: actions/checkout@v4 @@ -60,13 +59,11 @@ jobs: echo "tags=${BASE}:${TAG},${BASE}:latest" >> "$GITHUB_OUTPUT" echo "version=${TAG}" >> "$GITHUB_OUTPUT" echo "image_ref=${BASE}:${TAG}" >> "$GITHUB_OUTPUT" - echo "is_tag=true" >> "$GITHUB_OUTPUT" else SHA7="${GITHUB_SHA::7}" echo "tags=${BASE}:main,${BASE}:${SHA7}" >> "$GITHUB_OUTPUT" echo "version=${SHA7}" >> "$GITHUB_OUTPUT" echo "image_ref=${BASE}:main" >> "$GITHUB_OUTPUT" - echo "is_tag=false" >> "$GITHUB_OUTPUT" fi - uses: docker/build-push-action@v6 @@ -102,19 +99,19 @@ jobs: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} IMAGE_REF: ${{ needs.build.outputs.image_ref }} - IS_TAG: ${{ needs.build.outputs.is_tag }} run: | REMOTE_CMD='cd "$DEPLOY_PATH" || exit 1 - if [ "$IS_TAG" = "true" ]; then - perl -i -pe "s|^LIBRENOTES_IMAGE=.*|LIBRENOTES_IMAGE=$IMAGE_REF|" .env - grep -q "^LIBRENOTES_IMAGE=" .env || echo "LIBRENOTES_IMAGE=$IMAGE_REF" >> .env - fi + git pull --ff-only + # Always pin LIBRENOTES_IMAGE so first deploy works without manual + # .env priming and so rollback only ever needs an .env edit. + perl -i -pe "s|^LIBRENOTES_IMAGE=.*|LIBRENOTES_IMAGE=$IMAGE_REF|" .env + grep -q "^LIBRENOTES_IMAGE=" .env || echo "LIBRENOTES_IMAGE=$IMAGE_REF" >> .env docker compose -f compose.yaml -f compose.netcup.yaml pull docker compose -f compose.yaml -f compose.netcup.yaml up -d --remove-orphans' ssh -i ~/.ssh/id_deploy \ -o StrictHostKeyChecking=accept-new \ "$DEPLOY_HOST" \ - "DEPLOY_PATH='$DEPLOY_PATH' IMAGE_REF='$IMAGE_REF' IS_TAG='$IS_TAG' bash -s" <<< "$REMOTE_CMD" + "DEPLOY_PATH='$DEPLOY_PATH' IMAGE_REF='$IMAGE_REF' bash -s" <<< "$REMOTE_CMD" - name: Verify health env: -- 2.36.6