Provisions LXC 136 on scotty: an Alpine 3.23 container running Atheos (a lightweight PHP
browser editor with real vim keybindings) served by nginx + php-fpm, with armistace/blog cloned
into its workspace and ready to edit. LAN-only. Built with the AI-DLC workflow — the full
process trail (requirements → design → units → audit) is in aidlc-docs/.
Deliberately not VS Code / code-server / Coder, and deliberately not a build environment:
the Pelican build stays in the existing pipeline, so there is intentionally no Python here.
Design decisions
#
Decision
Why
D1
No git identity configured by the agent
The operator owns the armistace identity. Git refuses the first commit until they set it — deliberate, per requirement FR-11.
Atheos has no releases; a moving branch isn't reproducible. update-atheos.sh is the deliberate escape hatch.
D3
nginx :80, web root /var/www/atheos, php-fpm over unix socket
Smallest network surface.
D6
Per-repository deploy key, not a PAT
A Gitea PAT's scopes are route-groups matched against the whole account (write:repository reaches every repo the account can write). A deploy key reaches exactly one.
Verification — 40 checks, 0 failures, on the live guest
AC
Result
AC-2
Atheos served over HTTP 200; nginx/php-fpm socket paths byte-match
AC-3
20 posts + themes/cleanblog; owned by the php-fpm user
AC-4
All five writable paths writable as the service user
AC-5
Remote targets armistace/blog; deploy key authenticates over SSH
AC-6
Survival proven by an actual container restart; marker came back byte-identical
AC-8
No python/pelican/pip/make and no DB engine — scope boundary enforced, not just intended
FR-11
git identity empty; deploy key mode 600, owner = service user
Traps found and documented
Each of these cost real debugging time and is now in the RUNBOOK:
The Gitea SSH user is gitea, not git (app.ini SSH_USER=gitea). Using git makes sshd
answer Invalid user git and reject before reading authorized_keys — indistinguishable
from a bad key, and it sends you hunting for a problem that doesn't exist.
The Gitea SSH endpoint is the LAN address — public :22 is not forwarded (only :443).
uptime and free report the PVE host inside an LXC without lxcfs, so a container that
restarted seconds ago can claim days of uptime.
Test plan
scripts/build-and-test.sh on the guest → 40 passed, 0 failed
workspace/blog working tree clean (test markers removed)
No credential, private key, or identity committed (.gitignore covers keys)
## Summary
Provisions **LXC 136 on scotty**: an Alpine 3.23 container running **Atheos** (a lightweight PHP
browser editor with real vim keybindings) served by nginx + php-fpm, with `armistace/blog` cloned
into its workspace and ready to edit. LAN-only. Built with the **AI-DLC** workflow — the full
process trail (requirements → design → units → audit) is in `aidlc-docs/`.
Deliberately **not** VS Code / code-server / Coder, and deliberately **not a build environment**:
the Pelican build stays in the existing pipeline, so there is intentionally no Python here.
## Design decisions
| # | Decision | Why |
|---|---|---|
| D1 | **No git identity configured by the agent** | The operator owns the `armistace` identity. Git refuses the first commit until they set it — deliberate, per requirement FR-11. |
| D2 | **Atheos pinned to commit `f90cd18`** (build 611) + verified tarball sha256 | Atheos has no releases; a moving branch isn't reproducible. `update-atheos.sh` is the deliberate escape hatch. |
| D3 | nginx `:80`, web root `/var/www/atheos`, php-fpm over **unix socket** | Smallest network surface. |
| D6 | **Per-repository deploy key**, not a PAT | A Gitea PAT's scopes are route-groups matched against the whole account (`write:repository` reaches **every** repo the account can write). A deploy key reaches exactly one. |
## Verification — 40 checks, 0 failures, on the live guest
| AC | Result |
|---|---|
| AC-2 | Atheos served over HTTP **200**; nginx/php-fpm socket paths byte-match |
| AC-3 | 20 posts + `themes/cleanblog`; owned by the php-fpm user |
| AC-4 | All five writable paths writable **as the service user** |
| AC-5 | Remote targets `armistace/blog`; deploy key **authenticates over SSH** |
| AC-6 | Survival proven by an actual container restart; marker came back byte-identical |
| AC-8 | **No** python/pelican/pip/make and no DB engine — scope boundary enforced, not just intended |
| FR-11 | git identity empty; deploy key mode `600`, owner = service user |
## Traps found and documented
Each of these cost real debugging time and is now in the RUNBOOK:
1. **The Gitea SSH user is `gitea`, not `git`** (`app.ini SSH_USER=gitea`). Using `git` makes sshd
answer `Invalid user git` and reject **before reading `authorized_keys`** — indistinguishable
from a bad key, and it sends you hunting for a problem that doesn't exist.
2. **The Gitea SSH endpoint is the LAN address** — public `:22` is not forwarded (only `:443`).
3. **`uptime` and `free` report the PVE host** inside an LXC without lxcfs, so a container that
restarted seconds ago can claim days of uptime.
## Test plan
- [x] `scripts/build-and-test.sh` on the guest → **40 passed, 0 failed**
- [x] Container restarted; services returned; clone + edits intact
- [x] `workspace/blog` working tree clean (test markers removed)
- [x] No credential, private key, or identity committed (`.gitignore` covers keys)
Review round 1: code ✅ / security ✅ / infra ✅ Approved with two Minor/Needs-Change items.
Both adopted — neither blindly.
🔴 / 🟠 Adopted
1. provision.sh left the nginx vhost as a manual "see README" step.✅Fixed — a valid
catch: it broke this project's own repeatability requirement (FR-9), so a rebuild from scratch would
have served the distro default page. The script now writes the vhost itself, removes /etc/nginx/http.d/default.conf, and then asserts the socket contract
(nginx fastcgi_pass == php-fpm pool listen) before Atheos is installed — so a 502 can only ever
mean an nginx/php-fpm problem and can never be mistaken for an Atheos one. Verified: contract MATCH.
2. free/uptime reported the PVE host (a container restarted seconds earlier claimed "up 2
days"). ✅Fixed — but not with the suggested fix. The review proposed apk add lxcfs in the
guest. That is not the fix: lxcfs is a host-provided FUSE filesystem, not a guest package, and
measurement shows it is already working correctly:
The real cause: on Alpine free/uptime are busybox applets reading via the sysinfo()
syscall, which lxcfs cannot intercept. procps-ng ships binaries that read the virtualised /proc
files. Measured on the guest:
Tool
Reports
procps-ngfree
1024 MB
✅
busybox free
15701 MB
❌ host
procps-nguptime
up 2 min
✅
busybox uptime
up 2 days
❌ host
So provision.sh now installs procps-ng; build-and-test.sh reports container-accurate figures.
📝 Noted, deliberately not actioned
allow_url_fopen=On — already recorded as an accepted risk, required by the Atheos plugin
marketplace, and flagged again in docs/security-notes.md §2 (S-14) as the first thing to turn off
if this is ever exposed. No change.
chmod 775 / 664 breadth — intentional and load-bearing: the installer requires exactly those
writable paths (interface I-2), and nginx must read what the php-fpm user writes. Tightening further
would break the install. Recorded in docs/security-notes.md.
healthz endpoint — reasonable, out of scope for a LAN PoC with no monitoring (R-3/S-12 record
the same gap). Logged as a follow-up rather than merged scope creep.
Verification after the fixes
scripts/build-and-test.sh on the live guest: 40 passed, 0 failed. Resource line now reads 18MB used of 1024MB, up 2 min.
## Response to independent review
Review round 1: **code ✅ / security ✅ / infra ✅ Approved** with two Minor/Needs-Change items.
Both adopted — neither blindly.
### 🔴 / 🟠 Adopted
**1. `provision.sh` left the nginx vhost as a manual "see README" step.** ✅ **Fixed** — a valid
catch: it broke this project's own repeatability requirement (FR-9), so a rebuild from scratch would
have served the distro default page. The script now writes the vhost itself, removes
`/etc/nginx/http.d/default.conf`, and then **asserts the socket contract**
(`nginx fastcgi_pass` == `php-fpm pool listen`) *before* Atheos is installed — so a 502 can only ever
mean an nginx/php-fpm problem and can never be mistaken for an Atheos one. Verified: contract MATCH.
**2. `free`/`uptime` reported the PVE host** (a container restarted seconds earlier claimed *"up 2
days"*). ✅ **Fixed — but not with the suggested fix.** The review proposed `apk add lxcfs` in the
guest. That is **not** the fix: lxcfs is a host-provided FUSE filesystem, not a guest package, and
measurement shows it is already working correctly:
```
/proc/meminfo → MemTotal: 1048576 kB ← our 1024 MB limit
/proc/uptime → container seconds
/proc/cpuinfo → 2 processors
```
The real cause: on Alpine `free`/`uptime` are **busybox** applets reading via the **`sysinfo()`
syscall**, which lxcfs cannot intercept. `procps-ng` ships binaries that read the virtualised `/proc`
files. Measured on the guest:
| Tool | Reports | |
|---|---|---|
| `procps-ng` `free` | `1024 MB` | ✅ |
| busybox `free` | `15701 MB` | ❌ host |
| `procps-ng` `uptime` | `up 2 min` | ✅ |
| busybox `uptime` | `up 2 days` | ❌ host |
So `provision.sh` now installs `procps-ng`; `build-and-test.sh` reports container-accurate figures.
### 📝 Noted, deliberately not actioned
- **`allow_url_fopen=On`** — already recorded as an accepted risk, required by the Atheos plugin
marketplace, and flagged again in `docs/security-notes.md` §2 (S-14) as the first thing to turn off
if this is ever exposed. No change.
- **`chmod 775` / `664` breadth** — intentional and load-bearing: the installer requires exactly those
writable paths (interface I-2), and nginx must read what the php-fpm user writes. Tightening further
would break the install. Recorded in `docs/security-notes.md`.
- **`healthz` endpoint** — reasonable, out of scope for a LAN PoC with no monitoring (R-3/S-12 record
the same gap). Logged as a follow-up rather than merged scope creep.
### Verification after the fixes
`scripts/build-and-test.sh` on the live guest: **40 passed, 0 failed**. Resource line now reads
`18MB used of 1024MB, up 2 min`.
Independent review round 2 (incremental diff): **code ✅ Approved / security ✅ Approved / infra ✅
Approved** — "Ready for merge."
Review round 1 raised two items; both were addressed (one with a corrected diagnosis — the
suggested apk add lxcfs fix was wrong; the real cause was busybox using sysinfo(), and procps-ng is the fix). Everything else was an explicit, documented accepted risk.
Independent review round 2: all three domains Approved. Verified on the live guest at 40 checks / 0 failures, including a real container restart for data persistence.
Merging.
## Final Verdict
Review round 1 raised two items; both were addressed (one with a *corrected* diagnosis — the
suggested `apk add lxcfs` fix was wrong; the real cause was busybox using `sysinfo()`, and
`procps-ng` is the fix). Everything else was an explicit, documented accepted risk.
Independent review round 2: all three domains Approved. Verified on the live guest at
**40 checks / 0 failures**, including a real container restart for data persistence.
Merging.
Provisions a LAN-only browser code editor (Atheos) in an Alpine 3.23 LXC on the
scotty PVE node, with armistace/blog cloned and ready to edit. Built via the
AI-DLC workflow; the full process trail is in aidlc-docs/.
Deliverables
- scripts/provision.sh rebuild the guest from scratch (re-runnable)
- scripts/update-atheos.sh move Atheos to latest upstream and RE-PIN
- scripts/build-and-test.sh captures evidence for every acceptance criterion
- files/atheos.conf nginx vhost (socket path byte-matches the FPM pool)
- files/ssh-config ~atheos/.ssh/config deploy-key alias
- RUNBOOK.md operational guide (START AT STEP 1)
- docs/security-notes.md credential model + wider-web hardening TODOs
- docs/resiliency-notes.md what a non-PoC deployment would need
Design decisions
- D1 no git identity is configured by the agent; the operator sets it. Git will
refuse the first commit until then, which is deliberate (requirement FR-11).
- D2 Atheos pinned to commit f90cd18 (build 611) with a verified tarball sha256,
plus update-atheos.sh as the deliberate escape hatch.
- D3 nginx :80, web root /var/www/atheos, php-fpm over a unix socket.
- D6 git auth is a per-repository DEPLOY KEY scoped to armistace/blog, not a
personal access token (a PAT's write:repository scope reaches every repo the
account can write).
Verified on the live guest (40 checks, 0 failures)
- AC-2 Atheos served over HTTP 200 by nginx+php-fpm; socket paths byte-match
- AC-3 20 posts + themes/cleanblog present; owned by the php-fpm user
- AC-4 all five writable paths writable as the service user
- AC-5 remote targets armistace/blog; deploy key authenticates over SSH
- AC-6 survival proven with a real container restart, marker byte-identical
- AC-8 no python/pelican/pip/make and no DB engine - scope boundary enforced
- FR-11 git identity is empty; deploy key mode 600 owned by the service user
Traps documented in the RUNBOOK (each cost real debugging time)
- The Gitea SSH user is `gitea`, NOT `git` (app.ini SSH_USER=gitea). With `git`,
sshd answers "Invalid user git" and rejects before reading authorized_keys,
which is indistinguishable from a bad key.
- The Gitea SSH endpoint is the LAN address (:22 is not forwarded publicly).
- `uptime` and `free` report the PVE HOST inside an LXC without lxcfs, so a
container that just restarted can claim days of uptime.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Provisions LXC 136 on scotty: an Alpine 3.23 container running Atheos (a lightweight PHP
browser editor with real vim keybindings) served by nginx + php-fpm, with
armistace/blogclonedinto its workspace and ready to edit. LAN-only. Built with the AI-DLC workflow — the full
process trail (requirements → design → units → audit) is in
aidlc-docs/.Deliberately not VS Code / code-server / Coder, and deliberately not a build environment:
the Pelican build stays in the existing pipeline, so there is intentionally no Python here.
Design decisions
armistaceidentity. Git refuses the first commit until they set it — deliberate, per requirement FR-11.f90cd18(build 611) + verified tarball sha256update-atheos.shis the deliberate escape hatch.:80, web root/var/www/atheos, php-fpm over unix socketwrite:repositoryreaches every repo the account can write). A deploy key reaches exactly one.Verification — 40 checks, 0 failures, on the live guest
themes/cleanblog; owned by the php-fpm userarmistace/blog; deploy key authenticates over SSH600, owner = service userTraps found and documented
Each of these cost real debugging time and is now in the RUNBOOK:
gitea, notgit(app.ini SSH_USER=gitea). Usinggitmakes sshdanswer
Invalid user gitand reject before readingauthorized_keys— indistinguishablefrom a bad key, and it sends you hunting for a problem that doesn't exist.
:22is not forwarded (only:443).uptimeandfreereport the PVE host inside an LXC without lxcfs, so a container thatrestarted seconds ago can claim days of uptime.
Test plan
scripts/build-and-test.shon the guest → 40 passed, 0 failedworkspace/blogworking tree clean (test markers removed).gitignorecovers keys)Response to independent review
Review round 1: code ✅ / security ✅ / infra ✅ Approved with two Minor/Needs-Change items.
Both adopted — neither blindly.
🔴 / 🟠 Adopted
1.
provision.shleft the nginx vhost as a manual "see README" step. ✅ Fixed — a validcatch: it broke this project's own repeatability requirement (FR-9), so a rebuild from scratch would
have served the distro default page. The script now writes the vhost itself, removes
/etc/nginx/http.d/default.conf, and then asserts the socket contract(
nginx fastcgi_pass==php-fpm pool listen) before Atheos is installed — so a 502 can only evermean an nginx/php-fpm problem and can never be mistaken for an Atheos one. Verified: contract MATCH.
2.
free/uptimereported the PVE host (a container restarted seconds earlier claimed "up 2days"). ✅ Fixed — but not with the suggested fix. The review proposed
apk add lxcfsin theguest. That is not the fix: lxcfs is a host-provided FUSE filesystem, not a guest package, and
measurement shows it is already working correctly:
The real cause: on Alpine
free/uptimeare busybox applets reading via thesysinfo()syscall, which lxcfs cannot intercept.
procps-ngships binaries that read the virtualised/procfiles. Measured on the guest:
procps-ngfree1024 MBfree15701 MBprocps-nguptimeup 2 minuptimeup 2 daysSo
provision.shnow installsprocps-ng;build-and-test.shreports container-accurate figures.📝 Noted, deliberately not actioned
allow_url_fopen=On— already recorded as an accepted risk, required by the Atheos pluginmarketplace, and flagged again in
docs/security-notes.md§2 (S-14) as the first thing to turn offif this is ever exposed. No change.
chmod 775/664breadth — intentional and load-bearing: the installer requires exactly thosewritable paths (interface I-2), and nginx must read what the php-fpm user writes. Tightening further
would break the install. Recorded in
docs/security-notes.md.healthzendpoint — reasonable, out of scope for a LAN PoC with no monitoring (R-3/S-12 recordthe same gap). Logged as a follow-up rather than merged scope creep.
Verification after the fixes
scripts/build-and-test.shon the live guest: 40 passed, 0 failed. Resource line now reads18MB used of 1024MB, up 2 min.Independent review round 2 (incremental diff): code ✅ Approved / security ✅ Approved / infra ✅
Approved — "Ready for merge."
Final Verdict
Review round 1 raised two items; both were addressed (one with a corrected diagnosis — the
suggested
apk add lxcfsfix was wrong; the real cause was busybox usingsysinfo(), andprocps-ngis the fix). Everything else was an explicit, documented accepted risk.Independent review round 2: all three domains Approved. Verified on the live guest at
40 checks / 0 failures, including a real container restart for data persistence.
Merging.
8f07c377c0toa6fb61d0ea