Compare commits

..
11 Commits
Author SHA1 Message Date
libretech 9eefece320 feat(backup): add rsync --link-dest off-host transport, restore docs
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 14:47:42 +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
11 changed files with 254 additions and 151 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/libretech/runner-image.
container:
image: git.librete.ch/libretech/runner-image:v1
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/libretech/runner-image:v1
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
+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/libretech/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
+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