Compare commits

..
19 Commits
Author SHA1 Message Date
libretech 6dd20cc9bd ci: update runner-image path to public/ namespace
CI / ci (pull_request) Failing after 1s
The runner-image repo moved from libretech/ to public/ on Gitea;
ci.yml and deploy.yml + docs reference public/runner-image.
2026-04-30 11:53:41 +02:00
libretech ecd0ae69da ci: pin runner-image v2 (adds gcc for cgo, fixes go test -race)
v1 lacked gcc, so 'go test -race' (in Makefile) failed in CI with
'go: -race requires cgo'. v2 of the runner image installs gcc,
libc6-dev, and pkg-config.
2026-04-30 00:17:05 +02:00
libretech 942a888242 fix(auth): magic link points at /verify.html SPA (#46)
CI / ci (push) Failing after 6m23s
Deploy / deploy (push) Successful in 1m24s
2026-04-29 17:13:34 +02:00
libretech c5f92d6daa fix(auth): magic link points at /verify.html SPA, not /auth/verify JSON
CI / ci (pull_request) Failing after 6m21s
The link in the magic-link email landed users on the JSON API
endpoint /auth/verify, exposing the raw response body in the
browser. The SPA already ships the wrapper page (verify.html +
verify.js) — it reads the token, POSTs to /auth/verify itself,
saves the JWT, and redirects to /app.html.

Pointing the email at /verify.html restores the intended flow:
operator clicks link → verifying… → 'Signed in as <email>' →
'Go to your notes'.

Verified locally: the LogMailer now emits
http://localhost:18080/verify.html?token=…, GET /verify.html
returns the SPA HTML, and POST /auth/verify still returns the
JWT JSON (unchanged).
2026-04-29 17:12:58 +02:00
libretech 2c20edbe4e feat(backup): rsync --link-dest off-host transport, restore docs (#45)
Deploy / deploy (push) Successful in 1m17s
CI / ci (push) Failing after 5m37s
2026-04-29 15:17:42 +02:00
libretech 4947b64569 feat(backup): add rsync --link-dest off-host transport, restore docs
CI / ci (pull_request) Failing after 5m37s
backup.sh now supports BACKUP_REMOTE_RSYNC alongside BACKUP_REMOTE.
The rsync path writes <root>/YYYY-MM-DD/<archive> on the target with
--link-dest pointing at the previous day's directory, so unchanged
archives become hard links and daily snapshots cost almost zero
extra bytes. BACKUP_REMOTE_SSH_KEY routes the rsync ssh leg to a
dedicated identity (e.g. rsync.net restricted accounts).

Timer moved to 03:00 Europe/Berlin (was 03:17 UTC) per #41.

docs/operations.md: full restore procedure (parallel stack first,
then atomic swap) plus the rsync vs rclone trade-off. Closes most
of #41 — the only remaining task is the operator's choice of
off-host target.
2026-04-29 15:17:05 +02:00
libretech 3c31b79a8d fix(serve): mount /healthz on the public origin (#44)
Deploy / deploy (push) Has been cancelled
CI / ci (push) Has been cancelled
2026-04-29 15:16:30 +02:00
libretech 2508d4fe28 fix(serve): mount /healthz on the public origin
CI / ci (pull_request) Failing after 5m38s
The /healthz route was registered inside httpapi.Server.Routes() but
the root mux only attached that handler at /auth/ and /api/, so any
request to /healthz fell through to the static file server and got
404'd. Caddy's reverse-proxy and the deploy workflow's curl-based
health check both hit the public origin, so the in-container
healthcheck reported 'unhealthy' and CI never marked the deploy as
verified.

Mount /healthz on the root mux explicitly. Add a serve_test.go that
asserts the same routing topology so the regression cannot return
silently.
2026-04-29 15:15:59 +02:00
libretech 8b51e27c5a ci(deploy): retrigger after host LIBRENOTES_IMAGE seed
Deploy / deploy (push) Failing after 2m21s
CI / ci (push) Failing after 5m40s
2026-04-29 14:44:44 +02:00
libretech aa6b5b7e36 ci(deploy): retrigger after libretech-bot added to public Owners
Deploy / deploy (push) Failing after 1m19s
CI / ci (push) Has been cancelled
2026-04-29 14:41:49 +02:00
libretech bb730c2e67 ci(deploy): switch to libretech/runner-image:v1, consolidate workflow (#43)
Deploy / deploy (push) Failing after 1m50s
CI / ci (push) Failing after 5m37s
2026-04-29 14:27:05 +02:00
libretech 4ba7572565 ci(deploy): switch to libretech/runner-image:v1 and consolidate
CI / ci (pull_request) Failing after 5m52s
The deploy workflow is now a single job that builds, pushes, and
deploys in one runner. Tag computation moved to docker/metadata-action,
the per-deploy .env perl rewrite is gone (host pins LIBRENOTES_IMAGE
once; main pushes update :main rolling, releases pin to :vX.Y.Z by
manual edit), and both jobs run in our bespoke runner image whose
runner user already has socket access via group membership.

ci.yml moves to the same image so go/make/node are all available
without per-step apt installs.

Drops compose.prod.yaml (unused, redundant with compose.netcup.yaml).
2026-04-29 12:44:39 +02:00
libretech 6075aa22e1 fix(ci): drop docker.sock workflow mount (runner auto-mounts it)
Deploy / build (push) Failing after 2m18s
Deploy / deploy (push) Has been skipped
CI / ci (push) Successful in 11m11s
The Gitea act_runner already bind-mounts /var/run/docker.sock into job
containers when its own runner container has the socket mounted. The
workflow-level mount duplicated it and act_runner aborted the job with
'Duplicate mount point: /var/run/docker.sock'.
2026-04-29 12:17:25 +02:00
libretech 84b131d4f0 feat(deploy): publish image to Gitea Packages + switch netcup to image pull (#42)
Deploy / build (push) Failing after 0s
Deploy / deploy (push) Has been skipped
CI / ci (push) Successful in 12m33s
2026-04-29 11:47:01 +02:00
libretech 3d3fc41f73 ci(deploy): always pin LIBRENOTES_IMAGE and git pull on remote
CI / ci (pull_request) Successful in 12m34s
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.
2026-04-29 11:46:21 +02:00
libretech c261a23da8 docs(ops): document registry secrets, rollback, Gitea runner image
CI / ci (pull_request) Successful in 11m39s
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.
2026-04-29 02:00:29 +02:00
libretech 37842b6294 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.
2026-04-29 02:00:23 +02:00
libretech 856a480f53 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.
2026-04-29 02:00:15 +02:00
libretech d2c270b7f9 chore(compose): rename docker-compose.* to compose.* (Compose v2 spec) 2026-04-29 02:00:10 +02:00
14 changed files with 327 additions and 152 deletions

No files matched your search

+8 -1
View File
@@ -1,5 +1,12 @@
# Server-side .env for netcup deploy. Copy to /srv/librenotes/.env on the host. # 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. The default `:main` rolls forward — main pushes
# rebuild and push the same tag, so `compose pull` picks up the new
# digest without rewriting this file. To pin a release (or roll back),
# edit this line to an immutable tag such as :v0.1.0 and re-run
# `docker compose -f compose.yaml -f compose.netcup.yaml pull && up -d`.
LIBRENOTES_IMAGE=git.librete.ch/public/librenotes:main
# Public origin (caddy reverse-proxies to librenotes:8080) # Public origin (caddy reverse-proxies to librenotes:8080)
LIBRENOTES_BASE_URL=https://ln.cloud.librete.ch LIBRENOTES_BASE_URL=https://ln.cloud.librete.ch
+4
View File
@@ -9,6 +9,10 @@ on:
jobs: jobs:
ci: ci:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Bespoke runner image (Ubuntu 24.04 + make + git + node + go via
# actions/setup-go). See git.librete.ch/public/runner-image.
container:
image: git.librete.ch/public/runner-image:v2
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- name: Checkout - name: Checkout
+57 -60
View File
@@ -6,94 +6,91 @@ on:
tags: ["v*"] tags: ["v*"]
# Required repository secrets: # Required repository secrets:
# REGISTRY registry hostname, e.g. registry.librete.ch # REGISTRY registry hostname (git.librete.ch)
# REGISTRY_USER robot account # REGISTRY_USER robot account or PAT username (libretech-bot)
# REGISTRY_PASS robot token # REGISTRY_PASS PAT with write:package
# DEPLOY_HOST deployment SSH target, e.g. root@librenot.es # DEPLOY_HOST SSH target, e.g. root@cloud.librete.ch
# DEPLOY_KEY private SSH key (PEM, no passphrase) # DEPLOY_KEY passphrase-less private key (PEM)
# DEPLOY_PATH remote directory containing the compose stack # DEPLOY_PATH remote stack dir (/srv/librenotes)
# HEALTH_URL public URL to verify post-deploy, e.g. # HEALTH_URL https://ln.cloud.librete.ch/healthz
# https://librenot.es/healthz
# #
# Tag pushes deploy the tag (vX.Y.Z); main-branch pushes deploy # Required repository variable:
# the rolling :main image. Set image to immutable tag so rollback # DEPLOY_ENABLED set to "true" to enable the workflow
# is just `docker compose -f ... up -d` with the previous tag. #
# Image: ${REGISTRY}/public/librenotes
# main pushes → :main + :<sha7>
# tag pushes → :<tag> + :latest
#
# The host's /srv/librenotes/.env pins LIBRENOTES_IMAGE once
# (e.g. =git.librete.ch/public/librenotes:main). Main pushes
# update :main rolling so a `compose pull` picks up the new image
# without rewriting any file. Tag pin / rollback is a manual edit
# of LIBRENOTES_IMAGE in .env followed by `compose pull && up -d`.
jobs: jobs:
build: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 # Custom Gitea runner image: Ubuntu 24.04 + docker CLI + node + git
# + perl + ssh, runner user pre-joined to docker gid 998 so the
# auto-mounted /var/run/docker.sock is writable without --user root.
container:
image: git.librete.ch/public/runner-image:v2
timeout-minutes: 20
if: ${{ vars.DEPLOY_ENABLED == 'true' }} if: ${{ vars.DEPLOY_ENABLED == 'true' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: Log in to registry - uses: docker/login-action@v3
uses: docker/login-action@v3
with: with:
registry: ${{ secrets.REGISTRY }} registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }} password: ${{ secrets.REGISTRY_PASS }}
- name: Compute tags - id: meta
id: tags uses: docker/metadata-action@v5
run: | with:
BASE="${{ secrets.REGISTRY }}/librenotes" images: ${{ secrets.REGISTRY }}/public/librenotes
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then tags: |
TAG="${GITHUB_REF##refs/tags/}" type=ref,event=branch
echo "tags=${BASE}:${TAG},${BASE}:latest" >> "$GITHUB_OUTPUT" type=ref,event=tag
echo "version=${TAG}" >> "$GITHUB_OUTPUT" type=sha,format=short
else type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
echo "tags=${BASE}:main,${BASE}:${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
echo "version=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: . context: .
push: true push: true
tags: ${{ steps.tags.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
VERSION=${{ steps.tags.outputs.version }} VERSION=${{ steps.meta.outputs.version }}
BUILDTIME=${{ github.event.head_commit.timestamp }} BUILDTIME=${{ github.event.head_commit.timestamp }}
deploy: - name: Deploy to host
runs-on: ubuntu-latest
needs: build
timeout-minutes: 10
if: ${{ vars.DEPLOY_ENABLED == 'true' }}
steps:
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -H "${{ secrets.DEPLOY_HOST#*@ }}" >> ~/.ssh/known_hosts || true
- name: Pull and restart on deploy host
env: env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
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"
- name: Verify health
env:
HEALTH_URL: ${{ secrets.HEALTH_URL }} HEALTH_URL: ${{ secrets.HEALTH_URL }}
run: | run: |
# Give the new container ~30s to come up, then poll for mkdir -p ~/.ssh && chmod 700 ~/.ssh
# a 200 from /healthz. Failure aborts the workflow which printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_deploy
# is the alert. chmod 600 ~/.ssh/id_deploy
ssh -i ~/.ssh/id_deploy \
-o StrictHostKeyChecking=accept-new \
"$DEPLOY_HOST" \
"set -e
cd '$DEPLOY_PATH'
git pull --ff-only
docker compose -f compose.yaml -f compose.netcup.yaml pull
docker compose -f compose.yaml -f compose.netcup.yaml up -d --remove-orphans"
# Wait up to 60s for /healthz to return 200.
for i in $(seq 1 12); do for i in $(seq 1 12); do
if curl -fsS "$HEALTH_URL" >/dev/null; then curl -fsS "$HEALTH_URL" >/dev/null && exit 0
echo "deploy verified"
exit 0
fi
sleep 5 sleep 5
done done
echo "deploy verification failed" echo "deploy health check failed"; exit 1
exit 1
+2 -2
View File
@@ -18,7 +18,7 @@ Cloud-native multi-tenant notes application built on Notesium (MIT).
## Wave pipelines ## Wave pipelines
- `gt-issue-*` pipelines use `tea` CLI with `--login librete` - `gt-issue-*` pipelines use `tea` CLI with `--login libretech`
- `gh-issue-*` pipelines use `gh` CLI - `gh-issue-*` pipelines use `gh` CLI
- Personas must be registered in `wave.yaml` under `personas:` key — files alone aren't enough - Personas must be registered in `wave.yaml` under `personas:` key — files alone aren't enough
- `wave.yaml` is gitignored (local config) - `wave.yaml` is gitignored (local config)
@@ -27,6 +27,6 @@ Cloud-native multi-tenant notes application built on Notesium (MIT).
## Gitea ## Gitea
- Instance: https://git.librete.ch - Instance: https://git.librete.ch
- Auth: `tea` CLI, login name `librete` - Auth: `tea` CLI, login name `libretech` (matches the Gitea username)
- API: https://git.librete.ch/api/v1 - API: https://git.librete.ch/api/v1
- Issues: https://git.librete.ch/public/librenotes/issues - Issues: https://git.librete.ch/public/librenotes/issues
+5
View File
@@ -140,6 +140,11 @@ func runServe(args []string) error {
apiHandler := api.Routes() apiHandler := api.Routes()
root.Handle("/auth/", apiHandler) root.Handle("/auth/", apiHandler)
root.Handle("/api/", apiHandler) root.Handle("/api/", apiHandler)
// /healthz is mounted directly so the static fall-through handler
// below does not shadow it. The api.Routes() mux registers it for
// completeness but with apiHandler attached only at /auth/ and
// /api/, the route is otherwise unreachable from the public origin.
root.Handle("/healthz", apiHandler)
pub, err := fs.Sub(publicFS, "web/public") pub, err := fs.Sub(publicFS, "web/public")
if err != nil { if err != nil {
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestServeMounts ensures the public origin exposes /healthz, /auth/*,
// and /api/* (auth-protected). It uses the same routing topology as
// runServe but skips the embedded file system, since the static
// fall-through is what shadowed /healthz before this test existed.
func TestServeMounts(t *testing.T) {
root := http.NewServeMux()
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
_, _ = io.WriteString(w, `{"status":"ok"}`)
case "/auth/login":
w.WriteHeader(http.StatusMethodNotAllowed)
case "/api/whoami":
w.WriteHeader(http.StatusUnauthorized)
default:
http.NotFound(w, r)
}
})
root.Handle("/auth/", apiHandler)
root.Handle("/api/", apiHandler)
root.Handle("/healthz", apiHandler)
root.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
srv := httptest.NewServer(root)
defer srv.Close()
cases := []struct {
path string
want int
}{
{"/healthz", http.StatusOK},
{"/auth/login", http.StatusMethodNotAllowed},
{"/api/whoami", http.StatusUnauthorized},
{"/does-not-exist", http.StatusNotFound},
}
for _, tc := range cases {
resp, err := http.Get(srv.URL + tc.path)
if err != nil {
t.Fatalf("GET %s: %v", tc.path, err)
}
if resp.StatusCode != tc.want {
t.Errorf("%s: got %d, want %d", tc.path, resp.StatusCode, tc.want)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if tc.path == "/healthz" && !strings.Contains(string(body), `"status":"ok"`) {
t.Errorf("/healthz body = %q, want it to contain status:ok", body)
}
}
}
+13 -8
View File
@@ -1,15 +1,19 @@
# compose.netcup.yaml — overlay for the netcup VPS. # compose.netcup.yaml — overlay for the netcup VPS.
# #
# Use: # 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) # - No host port binding (caddy edge handles ingress)
# - Joins external `edge` network so caddy reaches it as `librenotes:8080` # - 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 # - Uses bind-mounted /data + /var/lib/librenotes so backups can rsync host paths
# #
# Inputs (env or .env file on netcup): # 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_BASE_URL https://ln.cloud.librete.ch
# LIBRENOTES_JWT_SECRET `openssl rand -base64 48` # LIBRENOTES_JWT_SECRET `openssl rand -base64 48`
# LIBRENOTES_SMTP_HOST SMTP relay host # LIBRENOTES_SMTP_HOST SMTP relay host
@@ -17,14 +21,15 @@
# LIBRENOTES_SMTP_USER SMTP user # LIBRENOTES_SMTP_USER SMTP user
# LIBRENOTES_SMTP_PASS SMTP password # LIBRENOTES_SMTP_PASS SMTP password
# LIBRENOTES_SMTP_FROM no-reply@librete.ch (envelope sender) # 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: services:
librenotes: librenotes:
build: build: !reset null
context: . image: ${LIBRENOTES_IMAGE}
args: pull_policy: always
VERSION: netcup
image: librenotes:netcup
restart: always restart: always
ports: !reset [] ports: !reset []
environment: environment:
+1 -1
View File
@@ -1,4 +1,4 @@
# docker-compose.yml — local development. # compose.yaml — local development.
# #
# Brings up librenotes on http://localhost:8080 with the magic-link # Brings up librenotes on http://localhost:8080 with the magic-link
# mailer logging links to stdout (no SMTP needed). Data lives in # mailer logging links to stdout (no SMTP needed). Data lives in
-43
View File
@@ -1,43 +0,0 @@
# docker-compose.prod.yml — production overrides.
#
# Use:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# Inputs (env or .env file):
# LIBRENOTES_IMAGE registry image, e.g. registry.librete.ch/librenotes:1.2.3
# LIBRENOTES_BASE_URL public origin, e.g. https://librenot.es
# LIBRENOTES_JWT_SECRET secrets manager value, NOT committed
# LIBRENOTES_SMTP_HOST real SMTP host
# LIBRENOTES_SMTP_PORT submission port (587 default)
# LIBRENOTES_SMTP_USER SMTP credential
# LIBRENOTES_SMTP_PASS SMTP credential
# LIBRENOTES_SMTP_FROM envelope sender (e.g. no-reply@librenot.es)
services:
librenotes:
image: ${LIBRENOTES_IMAGE}
build: !reset null
environment:
LIBRENOTES_BASE_URL: ${LIBRENOTES_BASE_URL}
LIBRENOTES_JWT_SECRET: ${LIBRENOTES_JWT_SECRET}
LIBRENOTES_SMTP_HOST: ${LIBRENOTES_SMTP_HOST}
LIBRENOTES_SMTP_PORT: ${LIBRENOTES_SMTP_PORT:-587}
LIBRENOTES_SMTP_USER: ${LIBRENOTES_SMTP_USER}
LIBRENOTES_SMTP_PASS: ${LIBRENOTES_SMTP_PASS}
LIBRENOTES_SMTP_FROM: ${LIBRENOTES_SMTP_FROM}
healthcheck:
test: ["CMD", "/librenotes", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 64M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
+126 -27
View File
@@ -21,51 +21,75 @@ The workflow expects these secrets and variables on the repo:
| Name | Type | Purpose | | Name | Type | Purpose |
| ---- | ---- | ------- | | ---- | ---- | ------- |
| `REGISTRY` | secret | hostname of the OCI registry | | `REGISTRY` | secret | registry hostname, e.g. `git.librete.ch` |
| `REGISTRY_USER` | secret | robot account | | `REGISTRY_USER` | secret | robot account or PAT username with `package:write` |
| `REGISTRY_PASS` | secret | robot token | | `REGISTRY_PASS` | secret | robot/PAT token |
| `DEPLOY_HOST` | secret | `user@host` SSH target | | `DEPLOY_HOST` | secret | `user@host` SSH target, e.g. `root@netcup` |
| `DEPLOY_KEY` | secret | passphrase-less private key | | `DEPLOY_KEY` | secret | passphrase-less private key (PEM) |
| `DEPLOY_PATH` | secret | absolute path on host with `docker-compose.*.yml` | | `DEPLOY_PATH` | secret | absolute path on host with the compose stack, e.g. `/srv/librenotes` |
| `HEALTH_URL` | secret | e.g. `https://librenot.es/healthz` | | `HEALTH_URL` | secret | e.g. `https://ln.cloud.librete.ch/healthz` |
| `DEPLOY_ENABLED` | variable | `true` to enable the workflow | | `DEPLOY_ENABLED` | variable | `true` to enable the workflow |
The image is pushed to `${REGISTRY}/public/librenotes`; main pushes
publish `:main` and `:<sha7>`, tag pushes publish `:vX.Y.Z` and
`:latest`.
### Production compose stack ### Production compose stack
On the deployment host, place `docker-compose.yml` and On the deployment host, place `compose.yaml` and
`docker-compose.prod.yml` from this repo at `$DEPLOY_PATH`, `compose.netcup.yaml` from this repo at `$DEPLOY_PATH`, together
together with an `.env` file containing the runtime configuration with an `.env` file containing `LIBRENOTES_IMAGE` plus the runtime
(JWT secret, SMTP credentials, public base URL, image tag). configuration (JWT secret, SMTP credentials, public base URL).
See `.env.netcup.example` for the full key list.
Bring it up with: Bring it up with:
```sh ```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 ### Rollback
Image tags are immutable per commit / version. To roll back, set Tag pushes publish immutable tags (`:vX.Y.Z`). To pin or roll back,
`LIBRENOTES_IMAGE` to the previous tag in `.env` and run the same edit `LIBRENOTES_IMAGE` in `/srv/librenotes/.env` and re-run pull +
`up -d` command. The deployment workflow does not auto-rollback up. The deployment workflow itself never rewrites the file — failed
on health-check failure — failed health alerts the operator via health checks abort the workflow and the operator redeploys
the workflow itself, who can then redeploy the prior tag manually. manually.
```sh ```sh
# Example: roll back to v0.1.2 # Example: roll back to v0.1.0
sed -i 's/^LIBRENOTES_IMAGE=.*/LIBRENOTES_IMAGE=registry.librete.ch\/librenotes:v0.1.2/' .env ssh netcup
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d cd /srv/librenotes
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 `ci.yml` and `deploy.yml` declare
`container: image: git.librete.ch/public/runner-image:v1` —
a bespoke Ubuntu 24.04 image (built and signed by us, hosted on
the same Gitea instance) that bundles `git`, `make`, `node`,
`perl`, `ssh`, and a docker CLI. The image's `runner` user is
pre-joined to `docker` group gid 998 so the auto-mounted
`/var/run/docker.sock` is writable without `--user root`.
The runner config (`runner/config.yaml`) whitelists
`/var/run/docker.sock` under `valid_volumes` to allow the
auto-mount.
## Backups ## Backups
`scripts/backup.sh` is a self-contained backup driver suitable for `scripts/backup.sh` is a self-contained backup driver suitable for
running from cron, a systemd timer, or the supplied running from cron, a systemd timer, or the supplied
`scripts/librenotes-backup.{service,timer}` units. `scripts/librenotes-backup.{service,timer}` units.
Required tools on the backup host: `sqlite3`, `tar`, `gzip`, and Required tools on the backup host: `sqlite3`, `tar`, `gzip`,
optionally `rclone` for off-site copy. On Debian/Ubuntu: `rsync`, and optionally `rclone` for object-store off-site copy.
`apt install sqlite3 rclone`. On Debian/Ubuntu: `apt install sqlite3 rsync rclone`.
### What is backed up ### What is backed up
@@ -79,10 +103,25 @@ optionally `rclone` for off-site copy. On Debian/Ubuntu:
### Off-site copy ### Off-site copy
Set `BACKUP_REMOTE` to an `rclone` destination (e.g. The script supports two off-site transports; either or both can be
`s3:librenotes-backups`). When set, the script invokes configured. Without either, backups stay on local disk only.
`rclone copy` to upload the archive after creation. Without it,
backups stay on the local disk only. - `BACKUP_REMOTE` — `rclone` destination (e.g.
`s3:librenotes-backups`, `b2:librenotes`). Uses `rclone copy` to
upload each new archive. Best for object-store targets.
- `BACKUP_REMOTE_RSYNC` — `rsync` destination (e.g.
`backup@rsync.net:librenotes/`). Each run rsyncs into
`<root>/YYYY-MM-DD/` and passes
`--link-dest=../<previous-day>/`, so unchanged archives become
hard links — daily snapshots cost (almost) nothing extra. Set
`BACKUP_REMOTE_SSH_KEY` to the path of a passphrase-less private
key if the host's default identity is wrong (recommended for
rsync.net-style restricted accounts).
For netcup, the operator's choice is captured in
[issue #41](https://git.librete.ch/public/librenotes/issues/41).
Until a target is chosen, leave both unset and run the timer
locally to verify archives generate cleanly.
### Retention ### Retention
@@ -115,3 +154,63 @@ Confirm with:
```sh ```sh
systemctl list-timers librenotes-backup.timer systemctl list-timers librenotes-backup.timer
``` ```
### Restore procedure
A restore is a four-step process. Run it on a *spare* directory
(e.g. `/srv/librenotes-restore-YYYYMMDD/`) — never overwrite the
live `/srv/librenotes/data` or `/srv/librenotes/state` until the
parallel stack passes verification.
```sh
# 1. Pick an archive (local or pulled back from the off-host target).
ARCHIVE=/var/backups/librenotes/librenotes-20260415-031712.tar.gz
# 2. Lay out a parallel stack dir.
RESTORE_DIR=/srv/librenotes-restore
mkdir -p "$RESTORE_DIR"/{data,state}
chown -R 65532:65532 "$RESTORE_DIR"/{data,state} # distroless nonroot uid
# 3. Unpack the archive into a scratch dir, then move the pieces
# into place.
SCRATCH=$(mktemp -d)
tar -C "$SCRATCH" -xzf "$ARCHIVE"
install -m 0644 -o 65532 -g 65532 "$SCRATCH/librenotes.db" \
"$RESTORE_DIR/state/librenotes.db"
tar -C "$RESTORE_DIR/data" -xzf "$SCRATCH/notes.tar.gz"
chown -R 65532:65532 "$RESTORE_DIR/data"
rm -rf "$SCRATCH"
# 4. Bring up a parallel compose stack (dedicated docker compose
# project name + non-conflicting LIBRENOTES_BASE_URL) and verify.
cd "$RESTORE_DIR"
cp /srv/librenotes/.env .env
perl -i -pe 's|^LIBRENOTES_BASE_URL=.*|LIBRENOTES_BASE_URL=http://localhost:18080|' .env
cp /srv/librenotes/{compose.yaml,compose.netcup.yaml} .
# Override port + project name so the parallel stack does not
# clash with the live one.
docker compose -p librenotes-restore \
-f compose.yaml \
--profile=restore \
up -d \
-e LIBRENOTES_PORT=18080
curl -fsS http://localhost:18080/healthz
# Sign in with a known test user, click around, confirm note state.
docker compose -p librenotes-restore down
```
Once the parallel stack proves the archive is intact, swap into
production with a maintenance window:
```sh
docker compose -f compose.yaml -f compose.netcup.yaml down
mv /srv/librenotes/data /srv/librenotes/data.bak-$(date +%s)
mv /srv/librenotes/state /srv/librenotes/state.bak-$(date +%s)
mv /srv/librenotes-restore/data /srv/librenotes/data
mv /srv/librenotes-restore/state /srv/librenotes/state
docker compose -f compose.yaml -f compose.netcup.yaml up -d
curl -fsS https://ln.cloud.librete.ch/healthz
```
If anything goes wrong, the `.bak-*` directories are still there
to restore from.
+11 -5
View File
@@ -23,10 +23,16 @@ Docker Compose. For day-2 ops (deploys, backups) see
```sh ```sh
git clone https://git.librete.ch/public/librenotes git clone https://git.librete.ch/public/librenotes
cd librenotes cd librenotes
cp .env.example .env # edit values, see below cp .env.netcup.example .env # edit values, see below
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
``` ```
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. Visit `https://your-domain/` and sign in.
## Environment configuration ## 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_SMTP_FROM` | yes | Envelope sender, e.g. `no-reply@notes.example.com`. |
| `LIBRENOTES_DATA_DIR` | no | Default `/data` inside the container. | | `LIBRENOTES_DATA_DIR` | no | Default `/data` inside the container. |
| `LIBRENOTES_DB` | no | Default `/var/lib/librenotes/librenotes.db`. | | `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: Generate a JWT secret:
@@ -109,8 +115,8 @@ to use `curl`/`wget`).
Pull the new image and restart: Pull the new image and restart:
```sh ```sh
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull docker compose -f compose.yaml -f compose.netcup.yaml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d docker compose -f compose.yaml -f compose.netcup.yaml up -d
``` ```
To pin a specific version, set `LIBRENOTES_IMAGE` to that tag in To pin a specific version, set `LIBRENOTES_IMAGE` to that tag in
+6 -1
View File
@@ -99,7 +99,12 @@ func (s *Service) RequestLogin(ctx context.Context, email string) error {
if err != nil { if err != nil {
return err return err
} }
link := s.baseURL + "/auth/verify?token=" + url.QueryEscape(plaintext) // The magic link must land on the SPA page (verify.html), not the
// JSON API endpoint /auth/verify. The page reads the token from
// the URL, POSTs it to /auth/verify, stores the JWT and redirects
// to /app.html. Pointing the email at /auth/verify exposes the
// raw JSON body to anyone who clicks the link.
link := s.baseURL + "/verify.html?token=" + url.QueryEscape(plaintext)
if err := s.mailer.SendMagicLink(ctx, email, link); err != nil { if err := s.mailer.SendMagicLink(ctx, email, link); err != nil {
return fmt.Errorf("send mail: %w", err) return fmt.Errorf("send mail: %w", err)
} }
+28 -1
View File
@@ -13,10 +13,18 @@
# Optional env: # Optional env:
# BACKUP_DIR where to write archives (default /var/backups/librenotes) # BACKUP_DIR where to write archives (default /var/backups/librenotes)
# BACKUP_REMOTE rclone target for off-site copy (e.g. s3:bucket/path) # BACKUP_REMOTE rclone target for off-site copy (e.g. s3:bucket/path)
# BACKUP_REMOTE_RSYNC rsync destination for hard-link-deduplicated
# off-host copy, e.g. backup@rsync.net:librenotes/
# Layout written at the target:
# <root>/YYYY-MM-DD/librenotes-<ts>.tar.gz
# Each run passes --link-dest=../<previous>/ so
# unchanged archives cost zero extra bytes.
# BACKUP_REMOTE_SSH_KEY path to a private SSH key used for the rsync
# leg (passed via -e "ssh -i <key>").
# BACKUP_VERSION version string written into info.txt # BACKUP_VERSION version string written into info.txt
# #
# The script is intentionally a single self-contained file so it # The script is intentionally a single self-contained file so it
# can run on a minimal host with only sqlite3, tar, gzip, and # can run on a minimal host with only sqlite3, tar, gzip, rsync, and
# (optionally) rclone. # (optionally) rclone.
set -euo pipefail set -euo pipefail
@@ -58,3 +66,22 @@ if [ -n "${BACKUP_REMOTE:-}" ]; then
rclone copy "$archive" "$BACKUP_REMOTE" --quiet rclone copy "$archive" "$BACKUP_REMOTE" --quiet
echo "uploaded to $BACKUP_REMOTE" echo "uploaded to $BACKUP_REMOTE"
fi fi
if [ -n "${BACKUP_REMOTE_RSYNC:-}" ]; then
# Compute today / previous-day directory names. Layout at the
# remote target is <root>/YYYY-MM-DD/. We pass --link-dest pointing
# at yesterday's dir so unchanged archives become hard links —
# ~free for daily snapshots that are mostly identical.
today="$(date -u +%Y-%m-%d)"
yesterday="$(date -u -d 'yesterday' +%Y-%m-%d 2>/dev/null \
|| date -u -v-1d +%Y-%m-%d)"
ssh_opts=()
if [ -n "${BACKUP_REMOTE_SSH_KEY:-}" ]; then
ssh_opts=(-e "ssh -i $BACKUP_REMOTE_SSH_KEY -o StrictHostKeyChecking=accept-new")
fi
# Strip any trailing slash so we control the join.
remote="${BACKUP_REMOTE_RSYNC%/}"
rsync -a --link-dest="../$yesterday/" "${ssh_opts[@]}" \
"$archive" "$remote/$today/"
echo "rsync'd $archive -> $remote/$today/"
fi
+4 -3
View File
@@ -2,9 +2,10 @@
Description=Run librenotes backup nightly Description=Run librenotes backup nightly
[Timer] [Timer]
# 03:17 UTC nightly with up to 5min jitter so multiple machines on # 03:00 Europe/Berlin nightly with up to 5min jitter so multiple
# the same schedule don't all hit the off-site target at once. # machines on the same schedule don't all hit the off-site target
OnCalendar=*-*-* 03:17:00 UTC # at once.
OnCalendar=*-*-* 03:00:00 Europe/Berlin
RandomizedDelaySec=5min RandomizedDelaySec=5min
Persistent=true Persistent=true
Unit=librenotes-backup.service Unit=librenotes-backup.service