917 lines
34 KiB
Markdown
917 lines
34 KiB
Markdown
# Labyricorn website and deployment runbook
|
|
|
|
This repository is the canonical source for `www.labyricorn.com`. It contains
|
|
the editable Lektor project—not generated production files—and is also the
|
|
primary operations guide for building, deploying, verifying, rolling back, and
|
|
troubleshooting the site.
|
|
|
|
## Quick reference
|
|
|
|
| Item | Value |
|
|
| --- | --- |
|
|
| Production URL | `https://www.labyricorn.com` |
|
|
| Canonical Git repository | `https://git.labyricorn.com/Labyricorn/labyricorn-site.git` |
|
|
| Production branch | `main` |
|
|
| Production host | `ubuntu2-47` (`10.138.2.47`) |
|
|
| Repository checkout | `/srv/labyricorn/repo` |
|
|
| Active document root | `/srv/labyricorn/current` |
|
|
| Release storage | `/srv/labyricorn/releases` |
|
|
| Deployment command | `sudo deploy-labyricorn` |
|
|
| Rollback listing | `sudo rollback-labyricorn` |
|
|
| Rollback command | `sudo rollback-labyricorn RELEASE_NAME` |
|
|
| Deployment log | `/srv/labyricorn/logs/deploy.log` |
|
|
| Remote-project cache | `/srv/labyricorn/project-cache` |
|
|
| nginx site config | `/etc/nginx/sites-available/labyricorn` |
|
|
| Lektor executable | `/srv/labyricorn/.local/bin/lektor` |
|
|
| Deployment account | `labyricorn-deploy` |
|
|
|
|
## Architecture
|
|
|
|
```text
|
|
Gitea: Labyricorn/labyricorn-site (main) public project repositories
|
|
| |
|
|
| authenticated Git fetch | read-only Git/API sync
|
|
v v
|
|
/srv/labyricorn/repo /srv/labyricorn/project-cache
|
|
| |
|
|
+---------- validated build input -------+
|
|
|
|
|
| isolated Lektor build
|
|
v
|
|
/srv/labyricorn/releases/<UTC timestamp>-<commit>
|
|
|
|
|
| validated, atomic symlink switch
|
|
v
|
|
/srv/labyricorn/current
|
|
|
|
|
| read-only static files
|
|
v
|
|
nginx :80
|
|
|
|
|
| Cloudflare proxy and public TLS
|
|
v
|
|
https://www.labyricorn.com
|
|
```
|
|
|
|
Gitea is the source of truth. The production checkout is disposable state and
|
|
must not contain unpublished edits during deployment. Lektor's development
|
|
server is not part of production serving; nginx serves generated files only.
|
|
Remote project repositories remain authoritative for their own exhibition and
|
|
devlog records. They are never checked out into the site repository and cannot
|
|
modify trusted site models, templates, scripts, or content.
|
|
|
|
## Repository contents
|
|
|
|
```text
|
|
Labyricorn.lektorproject Lektor project definition
|
|
content/ Editable site content (`contents.lr` files)
|
|
content/tags/ Tracked tag registry and dedicated tag routes
|
|
models/ Lektor content models
|
|
templates/ Jinja templates
|
|
assets/static/ CSS, filtering JavaScript, favicon, and static assets
|
|
configs/project-sources.ini Approved public remote-project registry
|
|
scripts/project_sources.py Remote-content validator and importer
|
|
scripts/build_with_projects.py Isolated build/preview entry point
|
|
ops/deploy-labyricorn Versioned copy of the production deployment command
|
|
tests/ Importer validation tests
|
|
.gitea/workflows/ Push, schedule, and manual deployment automation
|
|
.gitignore Excludes generated and local files
|
|
README.md This runbook
|
|
```
|
|
|
|
Generated output such as `build/`, `dist/`, and `.lektor/` is intentionally
|
|
ignored and must not be committed.
|
|
|
|
## Routine content workflow
|
|
|
|
Edit in a normal Git checkout, review the changes, and push them to `main`.
|
|
A successful push triggers the repository's Gitea Action, which builds and
|
|
atomically activates that exact revision on the production host.
|
|
|
|
```bash
|
|
git switch main
|
|
git pull --ff-only origin main
|
|
|
|
# Edit content, templates, models, or assets.
|
|
|
|
python scripts/build_with_projects.py --output-path build
|
|
git status
|
|
git add <reviewed-files>
|
|
git commit -m "Describe the site change"
|
|
git push origin main
|
|
```
|
|
|
|
Watch the run under **Actions** in Gitea. Manual deployment remains available
|
|
for recovery or controlled operations:
|
|
|
|
```bash
|
|
sudo deploy-labyricorn
|
|
```
|
|
|
|
Do not edit generated files under `/srv/labyricorn/current` or
|
|
`/srv/labyricorn/releases`. They will be replaced by deployment and are not
|
|
source-controlled.
|
|
|
|
## Tags and filtering workflow
|
|
|
|
`content/tags/` is the tracked tag registry. Each child directory is a normal
|
|
Lektor record whose directory name is the canonical lowercase, hyphenated slug
|
|
used in entry data and URLs:
|
|
|
|
```text
|
|
content/tags/deployment/contents.lr -> /tags/deployment/
|
|
```
|
|
|
|
The tag record supplies the editor label and public description:
|
|
|
|
_model: tag
|
|
---
|
|
title: Deployment
|
|
---
|
|
summary: Build, release, rollout, verification, and rollback workflows.
|
|
|
|
Blog entries and articles store all tag slugs in one comma-separated `tags`
|
|
value. The entry model reads its tracked checkbox choices from the registry,
|
|
while the focused entry editors also accept arbitrary additional tags:
|
|
|
|
```text
|
|
tags: lektor, deployment, operations
|
|
```
|
|
|
|
A tag with a matching registry record is tracked: it links to a dedicated tag
|
|
page and participates in tag discovery. A tag without a matching record remains
|
|
visible using the existing nonlinked presentation but is excluded from tracked
|
|
discovery. Tracking an already-used freeform slug, or untracking an existing
|
|
slug, changes that behavior on the next build without rewriting content
|
|
entries. Slug renaming is different: it can require deliberately updating every
|
|
reference and is not provided by the tag editor.
|
|
|
|
`/tags/` lists every approved tag and counts its usage across projects, project
|
|
devlog entries, blog entries, and articles. Dedicated tag URLs combine matching
|
|
records from all four content types in one chronological discovery feed. Tag
|
|
links are normal links and work without JavaScript. On the Projects, Blog, and
|
|
Articles listing pages, `assets/static/tags.js` progressively adds instant
|
|
filtering and keeps the selected filter in the `tag` query parameter.
|
|
|
|
Remote-project languages and declared technologies are normalized to slugs and
|
|
matched only against this controlled vocabulary. Their approved matches become
|
|
clickable tags and a deduplicated `taxonomy_tags` field used by filters, counts,
|
|
and tag pages. Devlog topics follow the same rule and populate derived
|
|
`topic_tag_slugs` used by tag feeds and counts. A label that has no approved
|
|
record remains visible and non-clickable in mustard, produces a build warning,
|
|
and is excluded from tag discovery. Remote data never creates a tag record
|
|
automatically.
|
|
|
|
After taxonomy or filtering changes:
|
|
|
|
1. Run `python scripts/build_with_projects.py --output-path build`.
|
|
2. Verify `/tags/`, at least one dedicated tag URL, the three top-level section
|
|
listings, and a project devlog index.
|
|
3. Confirm tracked-tag counts cover projects, devlog entries, blog entries, and
|
|
articles; freeform entry tags remain visible but unlinked; and unapproved
|
|
remote labels are not counted.
|
|
4. Test filtering with JavaScript enabled and confirm tag links remain usable
|
|
without JavaScript.
|
|
5. Check keyboard operation plus desktop and narrow layouts.
|
|
6. If filtering JavaScript or tag styles changed, increment the corresponding
|
|
cache-busting version in `templates/base.html`.
|
|
|
|
## Repository-owned project pages and devlogs
|
|
|
|
The site can publish a project page whose source lives in the project's own
|
|
public Git repository. Thinkloom is the first configured source:
|
|
|
|
```text
|
|
https://git.labyricorn.com/Labyricorn/thinkloom-openai-hackathon
|
|
.labyricorn/
|
|
├── AGENTS.md
|
|
├── project/
|
|
│ ├── AGENTS.md
|
|
│ ├── contents.lr
|
|
│ └── icon.png
|
|
└── devlog/
|
|
├── AGENTS.md
|
|
├── contents.lr
|
|
└── <entry-slug>/contents.lr
|
|
```
|
|
|
|
The public routes are `/projects/thinkloom/`,
|
|
`/projects/thinkloom/devlog/`, and
|
|
`/projects/thinkloom/devlog/<entry-slug>/`. The project repository owns the
|
|
native Lektor records and approved images. This site owns their models,
|
|
templates, layout, repository-information card, and import policy. Imported
|
|
devlog entries also join the homepage Recent Activity feed, ordered with
|
|
projects, articles, and blog entries by publication date. The feed initially
|
|
shows six items and reveals up to six more per button activation. This is a
|
|
progressive enhancement implemented by `assets/static/activity.js`; without
|
|
JavaScript, the complete semantic feed remains visible.
|
|
|
|
`configs/project-sources.ini` is the allowlist. Each source declares a stable
|
|
project ID, credential-free HTTPS Git URL, public web/API URL, and branch. Add
|
|
or remove a source only through a reviewed site change. The importer also
|
|
checks the provider API and refuses a repository reported as private.
|
|
Remote Git commands run with an isolated empty home/config and interactive
|
|
credential lookup disabled, so the production account's site-repository
|
|
credential cannot silently turn a private project fetch into an authenticated
|
|
one. An anonymous API response of 401, 403, or 404 is treated as a visibility
|
|
failure rather than a metadata-cache condition.
|
|
|
|
The importer reads only these paths:
|
|
|
|
- `.labyricorn/project/contents.lr`
|
|
- `.labyricorn/project/*.{png,jpg,jpeg,webp}`
|
|
- `.labyricorn/devlog/contents.lr`
|
|
- `.labyricorn/devlog/<entry-slug>/contents.lr`
|
|
- approved image types within a devlog entry directory
|
|
|
|
Everything else is ignored, including remote `README.md` and `AGENTS.md`
|
|
instructions. Symbolic links, submodules, executable files, raw HTML,
|
|
unsupported models or schema versions, invalid slugs, unknown paths,
|
|
out-of-tree references, and invalid image signatures are rejected. Limits are
|
|
100 imported files, 5 MiB per file, and 20 MiB total per project snapshot.
|
|
Devlog `source_commit` values must exist in the repository and be ancestors of
|
|
the imported branch revision.
|
|
|
|
Every build uses a temporary copy of trusted site source, materializes validated
|
|
remote records into that copy, and invokes Lektor there. Imported files never
|
|
enter the site working tree. Git mirrors, state, and the last validated snapshot
|
|
live under the project cache. If a new snapshot or network fetch fails, the
|
|
build uses the last-known-good validated revision. Initial publication fails
|
|
when no valid snapshot exists. The generated
|
|
`.labyricorn-projects.json` records the site revision, imported project
|
|
revisions, metadata digests, synchronization status, and synchronization time.
|
|
|
|
The project card combines repository-owned content with public provider and Git
|
|
metadata: source and README links, default branch, latest commit, license,
|
|
languages, open issues, stars, forks, latest release, imported revision, and
|
|
sync time. Approved language and technology labels link into the shared tag
|
|
taxonomy, as do approved devlog topics, allowing either a project or a
|
|
development milestone to lead visitors to related writing. API metadata failure
|
|
uses cached metadata and is identified in the
|
|
sync status; it never causes an unvalidated new snapshot to replace a working
|
|
one.
|
|
|
|
To preview or build with remote project content, use the wrapper instead of
|
|
calling Lektor directly:
|
|
|
|
```bash
|
|
python scripts/build_with_projects.py --serve
|
|
python scripts/build_with_projects.py --output-path build
|
|
python -m unittest discover -s tests -v
|
|
```
|
|
|
|
The default development cache is `.cache/project-sources`, which is ignored by
|
|
Git. Lektor must be installed because the importer deliberately uses Lektor's
|
|
native record parser and the wrapper invokes the Lektor build/server. No custom
|
|
Lektor plugin or third-party project-import package is used.
|
|
|
|
## Local development
|
|
|
|
Install Lektor in an isolated Python environment. For example:
|
|
|
|
```bash
|
|
python3 -m venv .venv
|
|
. .venv/bin/activate
|
|
python -m pip install --upgrade pip
|
|
python -m pip install lektor
|
|
```
|
|
|
|
Run the local editor and preview server with validated project content:
|
|
|
|
```bash
|
|
python scripts/build_with_projects.py --serve
|
|
```
|
|
|
|
Run a production-style build:
|
|
|
|
```bash
|
|
python scripts/build_with_projects.py --output-path build
|
|
test -f build/index.html
|
|
```
|
|
|
|
The project currently has no third-party Lektor packages or plugins.
|
|
|
|
### Local management launcher
|
|
|
|
To check the checkout's Git synchronization state and open the focused desktop
|
|
editors from one small window, run:
|
|
|
|
```bash
|
|
python scripts/management_launcher.py
|
|
```
|
|
|
|
The launcher locates the repository containing its script, fetches metadata
|
|
from the configured `origin`, and reports the current branch, upstream,
|
|
working-tree changes, and ahead/behind state in plain language. It offers only
|
|
fast-forward pulls for a clean, remote-ahead checkout and normal, non-force
|
|
pushes for a local-ahead checkout. Diverged histories, missing tracking
|
|
configuration, and dirty pulls require manual review. Refresh rechecks the
|
|
state without restarting the window. Each editor is started as a separate
|
|
Python process and remains directly runnable with its documented command.
|
|
|
|
### Local blog editor
|
|
|
|
For a focused desktop form that creates and edits records under `content/blog/`,
|
|
run:
|
|
|
|
```bash
|
|
python scripts/blog_editor.py
|
|
```
|
|
|
|
The editor uses Tkinter from the Python standard library. It presents tracked
|
|
tag choices from `content/tags/`, accepts normalized additional/freeform tags,
|
|
writes normal `contents.lr` records to the working tree, and can delete a
|
|
loaded entry after explicit confirmation. Entry
|
|
deletion removes its complete record directory, including attachments, so the
|
|
confirmation identifies additional files before proceeding. The editor does
|
|
not commit, push, build, or deploy changes. Review saved or deleted records with
|
|
the routine content workflow above.
|
|
|
|
For a saved entry, **Choose Narration WAV…** converts a selected WAV using
|
|
`ffmpeg` from `PATH`, or the repository-provided `support/ffmpeg.exe` when it
|
|
is not installed system-wide, and writes the entry-local `narration.mp3`
|
|
attachment that the existing narration player recognizes. It uses a fixed
|
|
44.1 kHz mono, 96 kbps MP3 preset; replacing or removing narration requires
|
|
confirmation. If `ffmpeg` is unavailable or conversion fails, the existing
|
|
narration attachment is left unchanged.
|
|
|
|
### Local tracked tag editor
|
|
|
|
To create, edit, or untrack records in the tracked tag registry, run:
|
|
|
|
```bash
|
|
python scripts/tag_editor.py
|
|
```
|
|
|
|
The editor manages only `content/tags/<slug>/contents.lr`. Creating a tracked
|
|
record does not rewrite existing content that already uses the slug. Untracking
|
|
requires confirmation, removes only that tracked record, and leaves all tag
|
|
values in blog, article, project, and devlog content untouched. Display names
|
|
and summaries can be edited, but slugs cannot be renamed in this editor.
|
|
|
|
### Local article editor
|
|
|
|
For a focused desktop form that creates and edits records under
|
|
`content/articles/`, run:
|
|
|
|
```bash
|
|
python scripts/article_editor.py
|
|
```
|
|
|
|
The article editor follows the existing cover convention: a first Markdown
|
|
image referencing an article-local `cover-image.png` or `cover-image.jpg`.
|
|
Choose a PNG, JPG, or JPEG in the editor and it is copied beside `contents.lr`
|
|
with that canonical name. Replacing or removing a cover requires confirmation.
|
|
Tracked tags are selected from `content/tags/`; arbitrary additional tags may
|
|
be entered without creating tracked records.
|
|
The editor does not commit, push, build, or deploy changes; review all working
|
|
tree changes with the routine content workflow above.
|
|
|
|
The article editor also has the same saved-entry narration control: it converts
|
|
a selected WAV to the article-local `narration.mp3` attachment using `ffmpeg`
|
|
from `PATH` or `support/ffmpeg.exe`, with the fixed 44.1 kHz mono, 96 kbps MP3
|
|
preset. Replacement and removal require confirmation; failed conversion leaves
|
|
any existing narration unchanged.
|
|
|
|
### Local project editor
|
|
|
|
To add, edit, or remove a site-approved remote project source, run:
|
|
|
|
```bash
|
|
python scripts/project_editor.py
|
|
```
|
|
|
|
The project editor asks for one public GitHub or Gitea repository web URL and
|
|
derives the anonymous Git and provider API URLs stored in
|
|
`configs/project-sources.ini`. Project prose, technology labels, images, and
|
|
devlog records remain owned by the external project repository under
|
|
`.labyricorn/`. Its explicit **Check Devlog Status** action performs the
|
|
importer's public-provider and remote-snapshot validation against a disposable
|
|
read-only Git mirror, then removes that mirror. When setup is missing, the
|
|
editor directs the user to copy the standalone `devlog_editor.py` into the
|
|
project checkout, initialize and author the devlog, publish the changes, and
|
|
recheck. It never edits, commits, or pushes an external project repository.
|
|
|
|
## Production filesystem and permissions
|
|
|
|
```text
|
|
/srv/labyricorn/
|
|
├── repo/ Git working copy (not web-accessible)
|
|
├── releases/ Successful immutable builds
|
|
├── current -> releases/<release>
|
|
├── logs/
|
|
│ ├── deploy.log
|
|
│ └── deploy.lock
|
|
├── project-cache/ Bare mirrors, metadata, and last-known-good state
|
|
├── .local/bin/lektor pipx-installed Lektor
|
|
├── .netrc Git HTTPS credential (0600; secret)
|
|
├── .ssh/ Reserved deployment SSH material
|
|
└── README-DEPLOYMENT.md Supplemental host-local notes
|
|
```
|
|
|
|
The `labyricorn-deploy` account owns the checkout, releases, logs, Lektor
|
|
environment, and Git credential. nginx's `www-data` account can traverse the
|
|
base path and read active generated files, but cannot read the source checkout
|
|
or credential. nginx cannot modify either source or releases.
|
|
|
|
## Deployment behavior
|
|
|
|
`/usr/local/bin/deploy-labyricorn` performs the following sequence:
|
|
|
|
1. Changes from root to the unprivileged `labyricorn-deploy` account.
|
|
2. Acquires an exclusive `flock` lock to prevent concurrent deployments.
|
|
3. Refuses to proceed if the working tree contains any tracked or untracked
|
|
changes.
|
|
4. Fetches and prunes `origin`.
|
|
5. Verifies that `origin/main` exists.
|
|
6. Switches to local `main` and resets it exactly to `origin/main`.
|
|
7. Creates a unique release named with UTC time and the 12-character commit ID.
|
|
8. Runs `scripts/build_with_projects.py` with the persistent project cache and
|
|
an explicit output directory.
|
|
9. Fetches, validates, and imports each approved public project source into an
|
|
isolated temporary workspace before invoking Lektor.
|
|
10. Requires `index.html` and `.labyricorn-projects.json`.
|
|
11. Writes the full site commit ID to `.labyricorn-commit`.
|
|
12. Compares the site commit plus project commit/metadata digests with the
|
|
active release and removes the candidate as an unchanged no-op when equal.
|
|
13. Atomically replaces `/srv/labyricorn/current` with a relative symlink to
|
|
the new release.
|
|
14. Keeps the active release plus recent prior releases, targeting five total.
|
|
15. Logs start, failure, unchanged, success, release name, and commit.
|
|
|
|
Run it with:
|
|
|
|
```bash
|
|
sudo deploy-labyricorn
|
|
```
|
|
|
|
A failed build is removed before activation. The current symlink is not changed,
|
|
so the previously working site remains online.
|
|
|
|
### Deployment preflight
|
|
|
|
```bash
|
|
sudo -u labyricorn-deploy env HOME=/srv/labyricorn \
|
|
git -C /srv/labyricorn/repo status --short --branch
|
|
|
|
sudo -u labyricorn-deploy env HOME=/srv/labyricorn \
|
|
git -C /srv/labyricorn/repo fetch origin
|
|
```
|
|
|
|
The status must be clean. Do not bypass a dirty-tree refusal. Determine whether
|
|
the files are legitimate unpublished admin edits, then commit and push them or
|
|
remove them deliberately before deployment.
|
|
|
|
## Verify a deployment
|
|
|
|
```bash
|
|
readlink -f /srv/labyricorn/current
|
|
cat /srv/labyricorn/current/.labyricorn-commit
|
|
|
|
sudo -u labyricorn-deploy env HOME=/srv/labyricorn \
|
|
git -C /srv/labyricorn/repo rev-parse HEAD
|
|
|
|
sudo -u labyricorn-deploy env HOME=/srv/labyricorn \
|
|
git -C /srv/labyricorn/repo ls-remote origin refs/heads/main
|
|
|
|
nginx -t
|
|
systemctl is-active nginx
|
|
|
|
curl -I -H 'Host: www.labyricorn.com' \
|
|
-H 'X-Forwarded-Proto: https' http://127.0.0.1/
|
|
|
|
curl -I https://www.labyricorn.com/
|
|
```
|
|
|
|
The commit in the active release, local `HEAD`, and remote `main` should match.
|
|
The local nginx request with `X-Forwarded-Proto: https` should return `200`.
|
|
Public HTTP should redirect to HTTPS, and public HTTPS should return `200`.
|
|
|
|
## Rollback
|
|
|
|
List releases and identify the current one:
|
|
|
|
```bash
|
|
sudo rollback-labyricorn
|
|
```
|
|
|
|
Switch atomically to a previous successful release:
|
|
|
|
```bash
|
|
sudo rollback-labyricorn 20260812T024742Z-92475d2ec759
|
|
```
|
|
|
|
Use a release name printed by the listing command. Rollback does not build,
|
|
fetch, or alter Git. It only changes the `current` symlink and records the event
|
|
in the deployment log.
|
|
|
|
Verify afterward:
|
|
|
|
```bash
|
|
readlink -f /srv/labyricorn/current
|
|
cat /srv/labyricorn/current/.labyricorn-commit
|
|
curl -I -H 'Host: www.labyricorn.com' \
|
|
-H 'X-Forwarded-Proto: https' http://127.0.0.1/
|
|
```
|
|
|
|
A normal deployment after rollback rebuilds and reactivates the latest remote
|
|
`main` commit.
|
|
|
|
## nginx and URL handling
|
|
|
|
The site configuration is:
|
|
|
|
```text
|
|
/etc/nginx/sites-available/labyricorn
|
|
/etc/nginx/sites-enabled/labyricorn -> ../sites-available/labyricorn
|
|
```
|
|
|
|
nginx serves `/srv/labyricorn/current` and supports:
|
|
|
|
- `index.html` at directory-style URLs;
|
|
- direct static assets;
|
|
- optional `.html` fallback;
|
|
- generated `/404/index.html` with an HTTP `404` response;
|
|
- disabled directory browsing;
|
|
- rejection of hidden paths and common executable-script extensions;
|
|
- dedicated access and error logs.
|
|
|
|
Always validate before reloading nginx:
|
|
|
|
```bash
|
|
nginx -t
|
|
systemctl reload nginx
|
|
```
|
|
|
|
nginx logs:
|
|
|
|
```text
|
|
/var/log/nginx/labyricorn-access.log
|
|
/var/log/nginx/labyricorn-error.log
|
|
```
|
|
|
|
## HTTPS, Cloudflare, and DNS
|
|
|
|
Current production HTTPS terminates at Cloudflare. The origin nginx process
|
|
currently listens on port `80`, not `443`. For `www.labyricorn.com`, nginx uses
|
|
Cloudflare's `X-Forwarded-Proto` header to distinguish public HTTP from HTTPS:
|
|
|
|
- public HTTP redirects to `https://www.labyricorn.com`;
|
|
- Cloudflare-originated HTTPS requests are served without a redirect loop;
|
|
- `labyricorn.com` is configured at nginx to redirect to the canonical HTTPS
|
|
`www` hostname.
|
|
|
|
The apex `labyricorn.com` did not resolve during initial setup. Its redirect will
|
|
not work publicly until DNS/Cloudflare has an apex record routed to this origin.
|
|
|
|
Certbot and the nginx integration are installed, and `certbot.timer` is enabled,
|
|
but no origin certificate was issued during setup. Do not run Certbot blindly
|
|
behind Cloudflare. First decide whether the desired origin mode is a Cloudflare
|
|
Origin CA certificate, Let's Encrypt with a compatible challenge, or direct DNS
|
|
without the proxy. After changing TLS, test both origin and public behavior for
|
|
redirect loops.
|
|
|
|
Useful checks:
|
|
|
|
```bash
|
|
getent ahosts www.labyricorn.com
|
|
getent ahosts labyricorn.com
|
|
curl -I http://www.labyricorn.com/
|
|
curl -I https://www.labyricorn.com/
|
|
systemctl status certbot.timer
|
|
certbot certificates
|
|
```
|
|
|
|
## Firewall and exposed services
|
|
|
|
UFW is active with default incoming denial. Allowed services are:
|
|
|
|
- OpenSSH: TCP 22
|
|
- nginx HTTP/HTTPS profile: TCP 80 and 443
|
|
|
|
Inspect current state:
|
|
|
|
```bash
|
|
ufw status verbose
|
|
ss -lntup
|
|
```
|
|
|
|
Lektor's development port must never be opened in UFW or bound to `0.0.0.0`.
|
|
|
|
## Optional server-side Lektor admin
|
|
|
|
An optional unit exists at:
|
|
|
|
```text
|
|
/etc/systemd/system/lektor-admin.service
|
|
```
|
|
|
|
Current state: **disabled and stopped**. When started, it runs as
|
|
`labyricorn-deploy` and binds only to `127.0.0.1:5000`. It is not proxied by
|
|
nginx and production remains independent of it.
|
|
|
|
Start it temporarily:
|
|
|
|
```bash
|
|
systemctl start lektor-admin
|
|
systemctl status lektor-admin
|
|
ss -lntp | grep ':5000'
|
|
```
|
|
|
|
Access it securely through an SSH tunnel from an administrator workstation:
|
|
|
|
```bash
|
|
ssh -L 5000:127.0.0.1:5000 [email protected]
|
|
```
|
|
|
|
Then browse to `http://127.0.0.1:5000` on that workstation.
|
|
|
|
Stop it when finished:
|
|
|
|
```bash
|
|
systemctl stop lektor-admin
|
|
```
|
|
|
|
### Committing admin edits made on the server
|
|
|
|
Lektor can edit files, but it does not automatically create Git commits or push
|
|
them. The deployment account intentionally has authenticated Git write access so
|
|
a controlled admin workflow can publish server-side edits.
|
|
|
|
Review everything before committing:
|
|
|
|
```bash
|
|
sudo -u labyricorn-deploy env HOME=/srv/labyricorn bash
|
|
cd /srv/labyricorn/repo
|
|
git status
|
|
git diff
|
|
git add <reviewed-files>
|
|
git commit -m "Describe the admin edit"
|
|
git push origin main
|
|
exit
|
|
```
|
|
|
|
Then run `sudo deploy-labyricorn`. The deployment script will refuse to run
|
|
while uncommitted admin edits remain. This is deliberate protection against
|
|
silently discarding edits when it resets to `origin/main`.
|
|
|
|
Before enabling persistent or public admin access, add authentication and make a
|
|
separate security decision. Do not publish port 5000 directly and do not proxy
|
|
an unauthenticated editor through nginx.
|
|
|
|
## Git authentication and secrets
|
|
|
|
The site repository is accessed over authenticated HTTPS because the Gitea SSH
|
|
port was not reachable through `git.labyricorn.com` during setup. Approved
|
|
remote project repositories, including Thinkloom, are public and are fetched
|
|
without embedding credentials in their configured URLs.
|
|
|
|
Credential purpose and location:
|
|
|
|
| Purpose | Location | Permissions |
|
|
| --- | --- | --- |
|
|
| Gitea HTTPS fetch/push | `/srv/labyricorn/.netrc` | `0600`, owned by `labyricorn-deploy` |
|
|
| Reserved SSH identity | `/srv/labyricorn/.ssh/id_ed25519` | `0600`, owned by `labyricorn-deploy` |
|
|
|
|
Never place token contents, passwords, or private keys in this README, Git URLs,
|
|
shell scripts, logs, commits, issue trackers, or command history.
|
|
|
|
Check credential-file metadata without printing its contents:
|
|
|
|
```bash
|
|
stat -c '%A %U:%G %n' /srv/labyricorn/.netrc
|
|
```
|
|
|
|
If the Gitea token is rotated, replace `.netrc` as `labyricorn-deploy`, preserve
|
|
mode `0600`, and verify both fetch and push access before revoking the old token.
|
|
Avoid printing either credential during rotation.
|
|
|
|
## Logs and diagnostics
|
|
|
|
```bash
|
|
# Deployment and rollback history
|
|
tail -n 100 /srv/labyricorn/logs/deploy.log
|
|
|
|
# nginx service and request errors
|
|
journalctl -u nginx --since today
|
|
tail -n 100 /var/log/nginx/labyricorn-error.log
|
|
|
|
# Optional admin service
|
|
journalctl -u lektor-admin --since today
|
|
|
|
# Repository state
|
|
sudo -u labyricorn-deploy env HOME=/srv/labyricorn \
|
|
git -C /srv/labyricorn/repo status
|
|
|
|
# Active release
|
|
readlink -f /srv/labyricorn/current
|
|
cat /srv/labyricorn/current/.labyricorn-commit
|
|
|
|
# Services and sockets
|
|
systemctl status nginx
|
|
systemctl status certbot.timer
|
|
ss -lntup
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Deployment says the repository is dirty
|
|
|
|
Inspect as the deployment account:
|
|
|
|
```bash
|
|
sudo -u labyricorn-deploy env HOME=/srv/labyricorn \
|
|
git -C /srv/labyricorn/repo status --short
|
|
```
|
|
|
|
If these are intended admin edits, review, commit, and push them. Otherwise,
|
|
identify their origin before removing anything. Never use a destructive reset
|
|
until you are certain no work needs to be preserved.
|
|
|
|
### Git fetch or push fails
|
|
|
|
Check DNS, HTTPS, the remote URL, and credential permissions without displaying
|
|
the secret:
|
|
|
|
```bash
|
|
getent ahosts git.labyricorn.com
|
|
curl -I https://git.labyricorn.com/
|
|
sudo -u labyricorn-deploy env HOME=/srv/labyricorn \
|
|
git -C /srv/labyricorn/repo remote -v
|
|
stat -c '%A %U:%G %n' /srv/labyricorn/.netrc
|
|
```
|
|
|
|
An authentication failure usually means the token was revoked, expired, or
|
|
lacks repository access.
|
|
|
|
### Lektor build fails
|
|
|
|
Run a disposable diagnostic build; do not build into `current`:
|
|
|
|
```bash
|
|
test_dir=$(mktemp -d /srv/labyricorn/releases/.diagnostic.XXXXXX)
|
|
chown labyricorn-deploy:labyricorn-deploy "$test_dir"
|
|
sudo -u labyricorn-deploy env HOME=/srv/labyricorn \
|
|
/srv/labyricorn/.local/pipx/venvs/lektor/bin/python \
|
|
/srv/labyricorn/repo/scripts/build_with_projects.py \
|
|
--site-root /srv/labyricorn/repo \
|
|
--cache-path /srv/labyricorn/project-cache \
|
|
--output-path "$test_dir" \
|
|
--lektor /srv/labyricorn/.local/bin/lektor
|
|
```
|
|
|
|
Inspect the error and source. Remove the diagnostic directory only after
|
|
confirming its exact path and that it is not referenced by `current`.
|
|
|
|
### nginx returns an error
|
|
|
|
```bash
|
|
nginx -t
|
|
systemctl status nginx
|
|
readlink -f /srv/labyricorn/current
|
|
test -f /srv/labyricorn/current/index.html
|
|
tail -n 100 /var/log/nginx/labyricorn-error.log
|
|
```
|
|
|
|
Do not point nginx at the Git checkout or start Lektor as a production server.
|
|
|
|
### Public HTTPS loops or behaves differently from local HTTP
|
|
|
|
Check Cloudflare proxy mode and the forwarded protocol behavior:
|
|
|
|
```bash
|
|
curl -I -H 'Host: www.labyricorn.com' http://127.0.0.1/
|
|
curl -I -H 'Host: www.labyricorn.com' \
|
|
-H 'X-Forwarded-Proto: https' http://127.0.0.1/
|
|
curl -I http://www.labyricorn.com/
|
|
curl -I https://www.labyricorn.com/
|
|
```
|
|
|
|
The first origin request should redirect; the simulated Cloudflare HTTPS request
|
|
should return the site. A different result indicates a proxy/header or nginx
|
|
configuration problem.
|
|
|
|
### Remote project synchronization fails
|
|
|
|
Inspect the deployment log and active manifest without editing the cache:
|
|
|
|
```bash
|
|
tail -n 100 /srv/labyricorn/logs/deploy.log
|
|
cat /srv/labyricorn/current/.labyricorn-projects.json
|
|
```
|
|
|
|
`current` means the latest remote revision and provider metadata validated.
|
|
`metadata-cached` means Git content is current but provider metadata fell back
|
|
to its cache. `last-known-good` means the latest fetch or candidate snapshot
|
|
could not safely replace the previously validated commit. A first import with
|
|
no valid cached snapshot fails the deployment. Do not bypass validation by
|
|
copying remote files into `content/`, the build workspace, or an active release.
|
|
|
|
## Automatic deployment and project refresh
|
|
|
|
The workflow at `.gitea/workflows/deploy.yml` runs for three event types:
|
|
|
|
- every push to site `main`;
|
|
- every 30 minutes (`*/30 * * * *`, evaluated by Gitea in UTC);
|
|
- an authenticated manual `workflow_dispatch`.
|
|
|
|
All events run the same one-job deployment:
|
|
|
|
1. Run on the repository-scoped runner labelled `labyricorn-deploy`.
|
|
2. Execute the existing root-owned `deploy-labyricorn` command.
|
|
3. Confirm `/srv/labyricorn/current/.labyricorn-commit` equals the workflow's
|
|
site commit.
|
|
4. Request the site through nginx on the local origin and fail if it is unhealthy.
|
|
|
|
The action intentionally does not check out the repository into its own
|
|
workspace. The deployment script fetches `origin/main` using the dedicated
|
|
`labyricorn-deploy` account, validates a clean checkout, builds a new release,
|
|
and atomically changes the `current` symlink. Concurrent action runs share the
|
|
`labyricorn-production` concurrency group, and the deployment script also uses
|
|
a filesystem lock. A scheduled/manual run updates the site when an approved
|
|
project commit or public metadata digest changes. When neither the site nor any
|
|
project input changed, it validates and builds a candidate, removes it, logs an
|
|
`unchanged` result, and leaves the active symlink untouched.
|
|
|
|
### Trigger a refresh
|
|
|
|
From Gitea, open **Labyricorn/labyricorn-site → Actions → Deploy production**
|
|
and choose **Run workflow** for `main`.
|
|
|
|
Automation and coding assistants may dispatch the same workflow through
|
|
Gitea's authenticated API after pushing project publishing content:
|
|
|
|
```bash
|
|
export LABYRICORN_REFRESH_TOKEN='set this outside Git and shell history'
|
|
curl --fail --silent --show-error \
|
|
-X POST \
|
|
-H "Authorization: token $LABYRICORN_REFRESH_TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
--data '{"ref":"main"}' \
|
|
https://git.labyricorn.com/api/v1/repos/Labyricorn/labyricorn-site/actions/workflows/deploy.yml/dispatches
|
|
unset LABYRICORN_REFRESH_TOKEN
|
|
```
|
|
|
|
The token must be supplied through an approved credential store or ephemeral
|
|
environment and must have permission to dispatch Actions for the site
|
|
repository. Never put it in the URL, a project repository, `AGENTS.md`, logs,
|
|
or committed scripts. Send at most one dispatch after the relevant project
|
|
push, then verify the Action and public project revision. The scheduled run is
|
|
the fallback when no refresh credential is available. This is deliberately not
|
|
an unauthenticated public webhook or long-running URL listener, so it cannot be
|
|
spammed by anonymous requests.
|
|
|
|
### Runner installation
|
|
|
|
The production host runs official Gitea Runner 3.0.0 directly on Linux; Docker
|
|
is not installed or required for this workflow.
|
|
|
|
| Item | Value |
|
|
|---|---|
|
|
| systemd unit | `gitea-runner.service` |
|
|
| service account | `gitea-runner` |
|
|
| runner name | `labyricorn-production` |
|
|
| registration scope | `Labyricorn/labyricorn-site` repository |
|
|
| execution label | `labyricorn-deploy:host` |
|
|
| runner binary | `/usr/local/bin/gitea-runner` |
|
|
| runner configuration | `/etc/gitea-runner/config.yaml` |
|
|
| registration state | `/var/lib/gitea-runner/.runner` |
|
|
| job workspace | `/var/lib/gitea-runner/work` |
|
|
| sudo policy | `/etc/sudoers.d/gitea-runner-labyricorn` |
|
|
|
|
The runner account has no general administrative access. Its sudo policy allows
|
|
only this exact command without a password:
|
|
|
|
```text
|
|
/usr/local/bin/deploy-labyricorn
|
|
```
|
|
|
|
Useful checks:
|
|
|
|
```bash
|
|
systemctl status gitea-runner
|
|
journalctl -u gitea-runner -n 100 --no-pager
|
|
sudo -l -U gitea-runner
|
|
```
|
|
|
|
If the runner must be replaced, delete or disable it in **Repository Settings →
|
|
Actions → Runners**, generate a fresh repository registration token, and
|
|
register the replacement. Registration tokens and `.runner` contents are
|
|
credentials: never commit or print them. A push can still be deployed manually
|
|
with `sudo deploy-labyricorn` while the runner is unavailable.
|
|
|
|
## Change-control checklist
|
|
|
|
Before changing deployment, nginx, TLS, firewall, credentials, or admin access:
|
|
|
|
1. Inspect the live state and preserve unrelated configuration.
|
|
2. Back up the specific file being changed.
|
|
3. Validate syntax before reload or restart.
|
|
4. Keep SSH reachable before firewall changes.
|
|
5. Verify local origin behavior and public Cloudflare behavior separately.
|
|
6. Record operational changes in this README.
|
|
7. Commit and push source documentation to `main`.
|
|
|
|
This README describes the current verified installation on `ubuntu2-47`. Update
|
|
it whenever the operational architecture changes.
|