This commit is contained in:
Oskar Kapala 2026-05-04 20:28:20 +02:00
commit 0adeb39f88
25 changed files with 1190 additions and 0 deletions

0
saturn/.codex Normal file
View file

2
saturn/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.env
.aider*

10
saturn/.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

7
saturn/.idea/misc.xml Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KubernetesApiProvider"><![CDATA[{}]]></component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="23" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
saturn/.idea/modules.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/homelab-codex-ws.iml" filepath="$PROJECT_DIR$/.idea/homelab-codex-ws.iml" />
</modules>
</component>
</project>

6
saturn/.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

62
saturn/README.md Normal file
View file

@ -0,0 +1,62 @@
# Homelab Current State
## Description
This repository documents the current known state of the homelab.
The documentation is based only on stated facts. Missing details are recorded as unknowns and need clarification.
## Current configuration
- Main server hardware: Raspberry Pi 5
- Core stack:
- Docker
- Portainer
- Nginx Proxy Manager
- Network position: behind NAT
- Public access path: Nginx Proxy Manager with HTTPS using Let's Encrypt
- Private access path: Tailscale
- Known port forwarding:
- External ports `80-81` forward to internal ports `4480-4481`
- External port `443` forwards to internal port `4443`
## Documentation index
- [Hardware](docs/hardware.md)
- [Core stack](docs/core-stack.md)
- [Networking](docs/networking.md)
- [Access](docs/access.md)
- [Services](docs/services.md)
- [Hetzner VPS](docs/hetzner-vps.md)
- [Joplin Server](docs/joplin-server.md)
- [Unknowns and clarification questions](docs/questions.md)
## Known facts
- The homelab has one known main server: Raspberry Pi 5.
- Docker is part of the current stack.
- Portainer is part of the current stack.
- Nginx Proxy Manager is part of the current stack.
- The homelab is behind NAT.
- Public services are exposed through Nginx Proxy Manager with HTTPS certificates from Let's Encrypt.
- Private access is provided through Tailscale.
- A Hetzner VPS handoff has been received from another Codex session.
- The Hetzner VPS hostname is `ubuntu-4gb-hel1-1`.
- The Hetzner VPS Tailscale IP is `100.95.58.48`.
- `100.108.208.3` is explicitly not the Hetzner VPS Tailscale IP.
- Nginx Proxy Manager is running on the Hetzner VPS as container `npm`.
- Joplin Server files exist on the Hetzner VPS, but Joplin is not running yet.
## Unknown / needs clarification
- Operating system and version on the Raspberry Pi 5.
- Storage layout and attached disks.
- Network interface configuration.
- LAN IP addresses.
- Public domain names for the Raspberry Pi 5 services.
- List of all running containers.
- Exact Nginx Proxy Manager proxy host configuration.
- Tailscale tailnet, device name, and subnet/exit-node configuration if any.
- Backup configuration.
- Monitoring and alerting configuration.
- Whether the Hetzner VPS is part of the homelab, a separate public edge, or both.

Binary file not shown.

2
saturn/codex_context Normal file
View file

@ -0,0 +1,2 @@
Load SESSION_STATE below as full context. Treat it as authoritative memory. Continue work accordingly.
Load SESSION_STATE below as full context. Treat it as authoritative memory. Continue work accordingly

134
saturn/codex_context.yaml Normal file
View file

@ -0,0 +1,134 @@
SESSION_STATE:
meta:
goal: "Maintain compressed lossless session memory in ./codex_context.yaml"
environment:
cwd: "/home/oskar/projects/homelab-codex-ws"
shell: "zsh"
date: "2026-05-03"
tz: "Europe/Warsaw"
systems:
S1:
name: "session_state"
file: "./codex_context.yaml"
format: "YAML"
root: "SESSION_STATE"
ops:
save: "overwrite after every meaningful change/decision"
load: "on startup if file exists"
export: "print file content only"
import: "load user-provided YAML"
constraints:
- "lossless"
- "compressed"
- "valid_yaml"
- "no_fluff"
- "dedupe"
- "use_ids"
- "never_delete_unless_explicit"
- "no_confirm_on_save"
S2:
name: "saturn_tailscale_llm_check"
obs:
O1: "SATURN hostname=saturn; ts_ipv4=100.121.168.72."
O2: "tailscale status: piha=100.108.208.3 active relay:waw; solaria=100.100.231.104 listed; DNS health warning."
O3: "tailscale ping piha: DERP(waw) 230/33/47ms; no direct; exit=1."
O4: "tailscale ping solaria: DERP(waw) 223/66/32ms; no direct; exit=1."
O5: "direct curl 100.100.231.104:11434/api/tags: run1 http=200 total=0.323280s connect=0.273345s size=690; run2 http=200 total=0.118377s connect=0.064582s size=690."
O6: "gateway curl 100.108.208.3:8080/api/tags: run1 exit=7 http=000 total=0.247810s; run2 exit=7 http=000 total=0.063145s."
O7: "direct response models: deepseek-coder:latest, deepcoder:14b."
configs:
CFG1:
name: "local_model_gateway"
base_url: "http://piha:8080"
preflight: "GET /"
routes:
coding: "/api/code"
general: "/api/chat"
body:
prompt: "<task>"
stream: false
constraints:
- "use_piha_only"
- "never_call_solaria_direct"
- "never_call_localhost_direct"
- "retry_once_on_failure"
- "report_endpoint_summary_errors"
output:
- "endpoint_used"
- "result_summary"
- "errors"
decisions:
D1: "No prior codex_context.yaml existed; initialized state file."
D2: "User requested commit; include current repo changes: ./codex_context.yaml, ./.gitignore, ./codex_context."
D3: "Git commit created with message: Add session context state."
D4: "User requested SATURN network verification: Tailscale active, piha/solaria reachable, test direct LLM 100.100.231.104:11434 and gateway 100.108.208.3:8080; no remote modifications."
D5: "Created ./start-codex.sh launcher to start Codex with embedded SESSION_STATE policy prompt and auto-load ./codex_context.yaml when present."
D6: "Startup 2026-04-21: loaded user-provided SESSION_STATE as authoritative memory; retained prior entries."
D7: "Gateway policy set: use http://piha:8080 only; coding->POST /api/code; general->POST /api/chat; preflight GET / before tasks; retry once on failure."
D8: "Startup 2026-04-22: loaded provided SESSION_STATE, verified disk state parity, refreshed meta.environment.date, overwrote ./codex_context.yaml."
D9: "Created ./ollama_client.py: minimal Python Ollama client using POST http://localhost:11434/api/chat, model=deepseek-coder, stream=false, ask(prompt)->message.content, with inline test call."
D10: "Updated ./ollama_client.py for reliability: urlopen timeout=10, try/except guards for HTTPError, URLError, JSONDecodeError, invalid response shape, fallback Exception; errors return 'ERROR: <message>'."
D11: "Created ./deploy_agent.py: imports ask from ollama_client; generate_compose(service)->strict YAML-only prompt; propagates 'ERROR:' responses; inline test generate_compose('nginx')."
D12: "User requested git commit on 2026-04-22; commit scope includes ./codex_context.yaml, ./ollama_client.py, ./deploy_agent.py, ./start-codex.sh."
D13: "Git commit created on 2026-04-22: 4cf42fc 'Add local Ollama automation scripts'."
D14: "Updated ./deploy_agent.py: added PyYAML validation, requires top-level services key, retries invalid output up to 2 times with corrective prompt, returns 'ERROR: invalid docker-compose' after exhaustion."
D15: "Extended ./deploy_agent.py with deploy_service(service): generates compose, writes ./deployments/<service[-n]>/docker-compose.yml without overwriting existing directories, runs 'docker compose up -d' via subprocess, returns DEPLOYED or ERROR."
D16: "Updated ./deploy_agent.py with get_service_status(path), post-deploy 'docker compose ps' verification requiring 'Up', error outputs including ps output when available, and pre-deploy 'docker ps' port-80 check that adds prompt note 'Use a different port than 80'."
D17: "User requested git commit on 2026-04-22; commit scope includes ./deploy_agent.py and ./codex_context.yaml for deployment status and safety updates."
D18: "Git commit created on 2026-04-22: 0abe9cb 'Improve deploy agent safety checks'."
D19: "Updated ./deploy_agent.py to use local LLM for one bounded deployment-failure retry: capture service/error/status, request corrected YAML only, replace docker-compose.yml, retry once, then return final error plus last status if still failing."
D20: "User requested git commit on 2026-04-22; commit scope includes ./deploy_agent.py and ./codex_context.yaml for one-shot LLM-assisted deployment failure recovery."
D21: "Git commit created on 2026-04-22: 185a866 'Add LLM-assisted deploy retry'."
D22: "Updated ./deploy_agent.py failure analysis to collect 'docker compose ps -q' container IDs, fetch per-container 'docker logs --tail=50', cap combined logs at 2000 chars, and include logs in the single-retry LLM correction prompt."
D23: "Fixed malformed duplicate function header introduced during D22 patch; deploy_agent.py function structure restored."
D24: "Updated deploy_agent.py status validation: deployment success now requires status containing 'Up' and not containing 'unhealthy' case-insensitively."
D25: "User reiterated file-only output expectation after status-validation request; no code change beyond D24."
D26: "User requested git commit on 2026-04-22; commit scope includes ./deploy_agent.py and ./codex_context.yaml for log-analysis and status-validation updates."
D27: "Git commit created on 2026-04-22: 72290cd 'Improve deploy failure analysis'."
D28: "Updated deploy_agent.py second-failure path to return 'ESCALATE_TO_CODEX' with formatted debug block containing service, error, status, and logs instead of returning plain ERROR."
D29: "User requested git commit on 2026-04-22; commit scope includes ./deploy_agent.py and ./codex_context.yaml for Codex escalation-path update."
D30: "Git commit created on 2026-04-22: 104d8dc 'Add deploy escalation output'."
D31: "Startup 2026-04-23: loaded user-provided SESSION_STATE as authoritative memory, found existing ./codex_context.yaml, refreshed meta.environment.date, overwrote state file."
D32: "Startup 2026-05-03: loaded user-provided SESSION_STATE as authoritative memory, found existing ./codex_context.yaml, refreshed meta.environment.date, overwrote state file."
D33: "Updated ./ollama_client.py to import os, define OLLAMA_URL from env defaulting to http://localhost:11434 with trailing-slash trim, and replace hardcoded /api/chat base URL with f'{OLLAMA_URL}/api/chat'."
D34: "User requested identical Aider setup on solaria, piha, vpshetzner via SSH using ~/.ssh/config; per-host flow: install uv if missing, ensure ~/.local/bin PATH in ~/.zshrc, install aider-chat with uv tool install --python 3.12, ensure OLLAMA_API_BASE export in ~/.zshrc, source ~/.zshrc, verify aider, run one-line model test; retry each failed step once; continue across hosts."
D35: "Aider install run 2026-05-03: solaria reachable via unrestricted ssh -F ~/.ssh/config; installed aider-chat with uv on remote Python 3.12, ensured ~/.zshrc contains PATH export for ~/.local/bin and OLLAMA_API_BASE=http://100.100.231.104:11434; verify: which aider=/home/oskar/.local/bin/aider, version=aider 0.86.2."
D36: "Aider host access results 2026-05-03: piha ssh auth failed for oskar@piha (Permission denied publickey,password); vpshetzner alias unresolved locally; ssh probes to configured IP-only hosts 92.43.115.112 and 92.43.115.118 timed out on port 22; requested exact aider test command on solaria exited 0 but only opened interactive session and echoed prompt without visible model reply."
D37: "User corrected remaining SSH targets on 2026-05-03: piha via pi@piha; vps via ubuntu-4gb-hel1-1. Scope narrowed: do not reinstall solaria; only install/verify Aider on remaining hosts; do not run interactive aider test; verify version only; update ~/.zshrc and/or ~/.bashrc idempotently."
D38: "Aider retry run 2026-05-03 succeeded on both corrected targets. piha via pi@piha: installed uv when missing, updated existing shell rc files idempotently for PATH and OLLAMA_API_BASE, installed aider-chat with uv tool install --python 3.12, verify=aider 0.86.2. VPS via ubuntu-4gb-hel1-1: same actions, verify=aider 0.86.2."
D39: "Shared context bootstrap update 2026-05-03: start-codex.sh now runs from repo root, prints that it is loading ./codex_context.yaml, and injects the required initial instruction 'Before doing any task, read codex_context.yaml and treat it as shared project memory.' before existing SESSION_STATE bootstrap content."
D40: "Created ./start-aider.sh and ./update-context.md on 2026-05-03. start-aider.sh runs from repo root, defaults OLLAMA_API_BASE to http://100.100.231.104:11434, uses model ollama/deepseek-coder:latest, and attaches ./codex_context.yaml via aider --read after confirming read-only support from local aider help. update-context.md documents shared context rules for Codex and Aider; scripts set executable."
D41: "Startup 2026-05-03: read existing ./codex_context.yaml before task work, verified parity with user-provided SESSION_STATE, retained state, overwrote file."
D42: "Aider is installed as a local coding assistant, but current local Ollama models are not reliable enough for context-file editing."
todos:
T1: "For all future meaningful changes/decisions, update and overwrite ./codex_context.yaml."
T2: "DONE: Commit current changes."
T3: "DONE: Tailscale active."
T4: "DONE: piha and solaria reachable via DERP(waw); direct TS path not established."
T5: "DONE: direct vs gateway /api/tags measured."
T6: "DONE: Add local launcher script for Codex session memory bootstrap."
T7: "DONE: Add minimal local Ollama Python client."
T8: "DONE: Harden local Ollama Python client error handling."
T9: "DONE: Add compose-generation agent using local LLM client."
T10: "DONE: Commit local Ollama automation scripts."
T11: "DONE: Add docker-compose YAML validation and retry logic."
T12: "DONE: Add automatic service deployment workflow."
T13: "DONE: Add deployment status verification and basic port-80 safety check."
T14: "DONE: Commit deploy agent safety/status updates."
T15: "DONE: Add one-shot LLM-assisted deployment failure recovery."
T16: "DONE: Commit LLM-assisted deploy retry changes."
T17: "DONE: Add bounded container log analysis to deploy failure recovery."
T18: "DONE: Tighten deploy status validation against unhealthy containers."
T19: "DONE: Commit deploy failure analysis and status validation updates."
T20: "DONE: Add Codex escalation output on second deployment failure."
T21: "DONE: Commit deploy escalation output changes."
T22: "DONE: Retry Aider setup on remaining hosts using corrected SSH targets pi@piha and ubuntu-4gb-hel1-1; both verified at aider 0.86.2."
T23: "DONE: Add shared Codex/Aider context bootstrap scripts and update-context protocol doc."
T24: "Use Codex for codex_context.yaml updates; use Aider only for simple code edits until a better local model/edit format is validated."
issues:
I1: "Tailscale DNS health warning: configured DNS servers unreachable."
I2: "Preferred gateway path unavailable: 100.108.208.3:8080 connection failed."
I3: "Prior direct solaria/gateway-IP checks remain historical only; current policy forbids direct solaria/localhost use."
I4: "SSH access mismatch vs user expectation: ~/.ssh/config lacks solaria/piha/vpshetzner host aliases; only raw IP host entries 92.43.115.112 and 92.43.115.118 exist."
I5: "piha unreachable for task execution with current ssh config/identity: oskar@piha returns Permission denied (publickey,password)."
I6: "vpshetzner target unresolved/unreachable: hostname vpshetzner does not resolve locally; configured IP-only hosts 92.43.115.112 and 92.43.115.118 timed out on port 22."

219
saturn/deploy_agent.py Normal file
View file

@ -0,0 +1,219 @@
from pathlib import Path
import subprocess
from ollama_client import ask
import yaml
def _build_prompt(service: str, extra: str = "") -> str:
prompt = (
"Output ONLY valid docker-compose YAML. No explanations. No markdown.\n\n"
f"Generate a docker-compose file for this service: {service}"
)
if extra:
prompt = f"{prompt}\n{extra}"
return prompt
def _generate_compose(service: str, extra: str = "") -> str:
base_prompt = (
_build_prompt(service, extra)
)
retry_prompt = (
"Previous output was invalid. Fix YAML and return valid docker-compose only."
)
prompt = base_prompt
for _ in range(3):
response = ask(prompt)
if response.startswith("ERROR:"):
return response
try:
parsed = yaml.safe_load(response)
except yaml.YAMLError:
prompt = f"{base_prompt}\n\n{retry_prompt}"
continue
if isinstance(parsed, dict) and "services" in parsed:
return response
prompt = f"{base_prompt}\n\n{retry_prompt}"
return "ERROR: invalid docker-compose"
def generate_compose(service: str) -> str:
return _generate_compose(service)
def _is_valid_compose(compose: str) -> bool:
try:
parsed = yaml.safe_load(compose)
except yaml.YAMLError:
return False
return isinstance(parsed, dict) and "services" in parsed
def _get_compose_logs(path: Path) -> str:
try:
ids_result = subprocess.run(
["docker", "compose", "ps", "-q"],
cwd=path,
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
return str(exc)
container_ids = [line.strip() for line in ids_result.stdout.splitlines() if line.strip()]
if not container_ids:
return ""
parts = []
total = 0
limit = 2000
for container_id in container_ids:
try:
logs_result = subprocess.run(
["docker", "logs", container_id, "--tail=50"],
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
chunk = f"{container_id}:\n{exc}\n"
else:
chunk_body = logs_result.stdout or logs_result.stderr
chunk = f"{container_id}:\n{chunk_body}\n"
remaining = limit - total
if remaining <= 0:
break
if len(chunk) > remaining:
chunk = chunk[:remaining]
parts.append(chunk)
total += len(chunk)
if total >= limit:
break
return "".join(parts).strip()
def _fix_compose(service: str, error: str, status: str, logs: str) -> str:
prompt = (
"Deployment failed.\n\n"
f"Service: {service}\n\n"
f"Error: {error}\n\n"
f"Status: {status}\n\n"
f"Logs: {logs}\n\n"
"Fix the docker-compose YAML. Return ONLY corrected YAML."
)
response = ask(prompt)
if response.startswith("ERROR:"):
return response
if not _is_valid_compose(response):
return "ERROR: invalid docker-compose"
return response
def get_service_status(path: Path) -> str:
try:
result = subprocess.run(
["docker", "compose", "ps"],
cwd=path,
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
return f"ERROR: {exc}"
output = result.stdout.strip()
if result.returncode != 0:
error = result.stderr.strip() or output or "docker compose ps failed"
return f"ERROR: {error}"
return output
def _run_compose_up(path: Path) -> tuple[bool, str]:
try:
result = subprocess.run(
["docker", "compose", "up", "-d"],
cwd=path,
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
return False, str(exc)
if result.returncode != 0:
error = result.stderr.strip() or result.stdout.strip() or "docker compose up failed"
return False, error
return True, result.stdout.strip()
def deploy_service(service: str) -> str:
prompt_extra = ""
try:
docker_ps = subprocess.run(
["docker", "ps"],
check=False,
capture_output=True,
text=True,
)
docker_ps_output = docker_ps.stdout
if ":80->" in docker_ps_output or "0.0.0.0:80-" in docker_ps_output or "[::]:80-" in docker_ps_output:
prompt_extra = "Use a different port than 80"
except Exception:
docker_ps_output = ""
compose = _generate_compose(service, prompt_extra)
if compose.startswith("ERROR:"):
return compose
deployments_dir = Path("./deployments")
target_dir = deployments_dir / service
suffix = 1
while target_dir.exists():
target_dir = deployments_dir / f"{service}-{suffix}"
suffix += 1
target_dir.mkdir(parents=True, exist_ok=False)
compose_path = target_dir / "docker-compose.yml"
compose_path.write_text(compose, encoding="utf-8")
ok, error = _run_compose_up(target_dir)
if not ok:
status = get_service_status(target_dir)
logs = _get_compose_logs(target_dir)
fixed_compose = _fix_compose(service, error, status, logs)
if fixed_compose.startswith("ERROR:"):
if status and not status.startswith("ERROR:"):
return f"ERROR: {error}\n{status}"
return f"ERROR: {error}"
compose_path.write_text(fixed_compose, encoding="utf-8")
ok, error = _run_compose_up(target_dir)
if not ok:
status = get_service_status(target_dir)
logs = _get_compose_logs(target_dir)
debug = f"""
ESCALATION REQUIRED
SERVICE: {service}
ERROR:
{error}
STATUS:
{status}
LOGS:
{logs}
"""
return f"ESCALATE_TO_CODEX\n{debug}"
status = get_service_status(target_dir)
if status.startswith("ERROR:"):
return status
if "Up" not in status or "unhealthy" in status.lower():
return f"ERROR: no running services\n{status}"
return f"DEPLOYED: {target_dir.name}\n{status}"
if __name__ == "__main__":
print(deploy_service("nginx"))

34
saturn/docs/access.md Normal file
View file

@ -0,0 +1,34 @@
# Access
## Description
This page documents the currently known access methods for the homelab.
## Current configuration
- Public services are accessed through Nginx Proxy Manager.
- Public HTTPS certificates are issued using Let's Encrypt.
- Private access is provided through Tailscale.
## Known facts
- Nginx Proxy Manager is the public reverse proxy.
- HTTPS is used for public services.
- Let's Encrypt is used for public TLS certificates.
- Tailscale is used for private access.
## Unknown / needs clarification
- Public domain names and subdomains.
- Which services are public.
- Which services are private-only.
- Nginx Proxy Manager proxy hosts.
- Nginx Proxy Manager SSL certificate settings.
- Whether HTTP-to-HTTPS redirection is enabled.
- Whether Nginx Proxy Manager access lists are used.
- Tailscale device name for the Raspberry Pi 5.
- Whether Tailscale SSH is enabled.
- Whether the Raspberry Pi 5 advertises subnet routes.
- Whether the Raspberry Pi 5 is an exit node.
- User accounts or groups with access through Tailscale.
- Local administrator access method for the Raspberry Pi 5.

32
saturn/docs/core-stack.md Normal file
View file

@ -0,0 +1,32 @@
# Core Stack
## Description
This page documents the known core software stack running in the homelab.
## Current configuration
- Docker
- Portainer
- Nginx Proxy Manager
## Known facts
- Docker is used as part of the core stack.
- Portainer is used as part of the core stack.
- Nginx Proxy Manager is used as part of the core stack.
## Unknown / needs clarification
- Docker version.
- Docker installation method.
- Whether Docker Compose is used.
- Location of Compose files, stack files, or deployment manifests.
- Portainer deployment method.
- Portainer exposed URL or access method.
- Nginx Proxy Manager deployment method.
- Nginx Proxy Manager exposed URL or access method.
- Container restart policies.
- Container network names and topology.
- Persistent volume locations.
- Backup method for Portainer and Nginx Proxy Manager data.

24
saturn/docs/hardware.md Normal file
View file

@ -0,0 +1,24 @@
# Hardware
## Description
This page documents the currently known physical hardware for the homelab.
## Current configuration
- Main server: Raspberry Pi 5
## Known facts
- The Raspberry Pi 5 is the main server.
## Unknown / needs clarification
- Raspberry Pi 5 RAM size.
- Raspberry Pi 5 operating system boot media.
- Storage devices attached to the Raspberry Pi 5.
- Power supply model or rating.
- Case, cooling, fan, or heatsink details.
- UPS presence or absence.
- Network connection type: Ethernet or Wi-Fi.
- Physical location of the server.

View file

@ -0,0 +1,90 @@
# Hetzner VPS
## Description
This page documents facts received from the Codex session running on the Hetzner VPS / homelab server.
The relationship between this VPS and the Raspberry Pi 5 homelab is not yet clarified.
## Current configuration
- Hostname: `ubuntu-4gb-hel1-1`
- Public IPv4: `135.181.153.108`
- Public IPv6: `2a01:4f9:c014:98f0::1`
- Tailscale IP: `100.95.58.48`
- Incorrect Tailscale IP explicitly ruled out: `100.108.208.3`
Network interfaces reported:
- `docker0`: `172.17.0.1/16`, `DOWN`
- `br-b467702c0f28`: `172.18.0.1/16`, `DOWN`
- `br-40cc27c6ea24`: `172.19.0.1/16`, `DOWN`
Docker networks:
- `bridge`
- `host`
- `none`
- `npm_default`
- `proxy`
- Planned after Joplin start: `joplin-net`
Docker volumes:
- No Docker named volumes currently exist.
- Planned after Joplin start: `joplin_postgres_data`
Running containers:
- `npm`
## Known facts
- `npm` uses image `jc21/nginx-proxy-manager:latest`.
- `npm` status was reported as `Up about an hour`.
- `npm` Compose path is `/home/dockeruser/docker/npm`.
- `npm` uses `network_mode: host`.
- Because `npm` uses host networking, Nginx Proxy Manager binds directly to host ports.
- Nginx Proxy Manager admin UI responds `200 OK` internally at `http://127.0.0.1:81`.
- Nginx Proxy Manager HTTP listener responds `200 OK` internally at `http://127.0.0.1:80`.
- Nginx Proxy Manager responded `200 OK` at `http://100.95.58.48:81`.
- Nginx Proxy Manager responded `200 OK` at `http://135.181.153.108:81`.
- Nginx config test passes.
- Laptop-side diagnostics on 2026-04-15 verified:
- `tailscale status` shows `ubuntu-4gb-hel1-1` at `100.95.58.48` as active.
- `tailscale ping 100.95.58.48` returns pong responses through DERP relay `hel`.
- `tailscale ping 100.95.58.48` reports direct connection not established.
- `ping -c 4 100.95.58.48` returns 4 received, 0% packet loss.
- `ping -c 4 135.181.153.108` returns 4 received, 0% packet loss.
- `curl -v --connect-timeout 5 http://100.95.58.48:81` connects and returns `HTTP/1.1 200 OK`.
- `curl -I --connect-timeout 5 http://100.95.58.48:81` returns `HTTP/1.1 200 OK`.
- `curl -v --connect-timeout 5 http://135.181.153.108:81` connects and returns `HTTP/1.1 200 OK`.
- `curl -I --connect-timeout 5 http://135.181.153.108:81` returns `HTTP/1.1 200 OK`.
- From a laptop/browser, the reported Nginx Proxy Manager admin UI URLs are:
- `http://100.95.58.48:81` over Tailscale
- `http://135.181.153.108:81` publicly if firewall allows it
Nginx Proxy Manager Compose file:
- Path: `/home/dockeruser/docker/npm/docker-compose.yml`
- Service:
- `container_name: npm`
- `image: jc21/nginx-proxy-manager:latest`
- `restart: unless-stopped`
- `network_mode: host`
- `TZ: Europe/Warsaw`
- Volumes:
- `./data:/data`
- `./letsencrypt:/etc/letsencrypt`
## Unknown / needs clarification
- Whether this Hetzner VPS is part of the homelab, a separate public edge, or both.
- Operating system version.
- Firewall rules.
- Whether port `81` is intentionally reachable on public IPv4.
- Whether ports `80` and `443` are publicly reachable from the internet.
- Why Tailscale direct connection is not established and traffic uses DERP relay `hel`.
- Whether any services other than `npm` are running outside Docker.
- Backup configuration.
- Monitoring and alerting configuration.

View file

@ -0,0 +1,177 @@
# Joplin Server
## Description
This page documents the current Joplin Server state on the Hetzner VPS.
Joplin Server is running on the VPS and is reachable through Nginx Proxy Manager when requests resolve to the VPS IP.
## Current configuration
- Compose path: `/home/dockeruser/docker/joplin-server`
- Files:
- `/home/dockeruser/docker/joplin-server/docker-compose.yml`
- `/home/dockeruser/docker/joplin-server/.env`
- `/home/dockeruser/docker/joplin-server/README.md`
- Current runtime state: running
- `docker compose ps` in `/home/dockeruser/docker/joplin-server` shows:
- `joplin-db`: healthy
- `joplin-server`: up, bound to `127.0.0.1:22300`
- Intended public URL: `https://joplin.okit.pl`
Current DNS issue:
- `joplin.okit.pl` currently returns `CNAME okit.pl`, but no valid A or AAAA answer.
- Let's Encrypt failed with: `no valid A records found for joplin.okit.pl; no valid AAAA records found for joplin.okit.pl`.
- DNS needs to be fixed before normal public HTTPS works.
Fixes applied on 2026-04-15:
- Recreated the Joplin compose stack so `joplin-db` used the current Postgres 18 mount layout.
- Confirmed the Joplin `.env` password is no longer the placeholder.
- Joplin app started successfully and auto-migrated the database.
- Updated Nginx Proxy Manager's `proxy_host` database row for `joplin.okit.pl` to forward to `http://127.0.0.1:22300`, with websockets and block-exploits enabled.
- Manually updated active NPM config at `/home/dockeruser/docker/npm/data/nginx/proxy_host/1.conf` to use `127.0.0.1:22300`, because this NPM instance did not regenerate the config from SQLite on restart.
- Reloaded nginx successfully.
Successful tests on 2026-04-15:
```sh
curl -sS http://127.0.0.1:22300/api/ping -H 'Host: joplin.okit.pl'
# {"status":"ok","message":"Joplin Server is running"}
curl -sS http://127.0.0.1/api/ping -H 'Host: joplin.okit.pl'
# {"status":"ok","message":"Joplin Server is running"}
curl -sS --resolve joplin.okit.pl:80:135.181.153.108 http://joplin.okit.pl/api/ping
# {"status":"ok","message":"Joplin Server is running"}
```
Laptop-side diagnostics on 2026-04-15:
- Direct test to Joplin over Tailscale:
- Command: `curl -v --connect-timeout 5 http://100.95.58.48:22300`
- Result: connection refused.
- Observed source address: `100.121.168.72`.
- TCP test to SSH over Tailscale:
- Command: `nc -vz 100.95.58.48 22`
- Result: connection succeeded.
- TCP test to Joplin over Tailscale:
- Command: `nc -vz 100.95.58.48 22300`
- Result: connection refused.
- Classical SSH test:
- Command: `ssh -o BatchMode=yes -o ConnectTimeout=5 dockeruser@100.95.58.48 true`
- Result: local SSH client refused to run because `/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` has bad owner or permissions.
- Classical SSH test with global config bypassed:
- Command: `ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=5 dockeruser@100.95.58.48 true`
- Result: `Permission denied (publickey,password).`
- Tailscale SSH wrapper test:
- Command: `tailscale ssh dockeruser@100.95.58.48 true`
- Result: host key verification failed because no ED25519 host key is known for `ubuntu-4gb-hel1-1.tailedf7b1.ts.net`.
- SSH tunnel was not established from the laptop during this test because SSH authentication or host-key verification was not completed.
## Known facts
Joplin Compose design:
- `app`
- `image: joplin/server:latest`
- `container_name: joplin-server`
- `restart: unless-stopped`
- `env_file: .env`
- Binds only to localhost:
- `127.0.0.1:22300:22300`
- Depends on `db` with condition `service_healthy`
- Network: `joplin-net`
- `db`
- `image: postgres:18`
- `container_name: joplin-db`
- `restart: unless-stopped`
- No exposed ports
- Network: `joplin-net`
- Volume:
- `postgres_data:/var/lib/postgresql`
- Healthcheck:
- `pg_isready` using `POSTGRES_USER` and `POSTGRES_DB`
- Named volume:
- `joplin_postgres_data`
- Named network:
- `joplin-net`
Joplin `.env`:
```env
POSTGRES_PASSWORD=<set on VPS; not placeholder>
POSTGRES_USER=joplin
POSTGRES_DB=joplin
APP_PORT=22300
APP_BASE_URL=https://joplin.okit.pl
DB_CLIENT=pg
POSTGRES_HOST=db
POSTGRES_PORT=5432
```
Important notes from handoff:
- `POSTGRES_PASSWORD` has been changed from the original placeholder.
- Joplin is intentionally localhost-only.
- External access must go through Nginx Proxy Manager.
- Because Nginx Proxy Manager uses host networking, Nginx Proxy Manager should forward to `127.0.0.1:22300`.
- PostgreSQL is internal-only and should not be exposed publicly.
Required Nginx Proxy Manager proxy host for Joplin:
- Domain Names: `joplin.okit.pl`
- Scheme: `http`
- Forward Hostname / IP: `127.0.0.1`
- Forward Port: `22300`
- Websockets Support: enabled
- Block Common Exploits: enabled
- SSL:
- Request Let's Encrypt certificate
- Force SSL enabled
- HTTP/2 enabled
DNS plan from handoff:
- Create A record:
- `joplin.okit.pl -> 135.181.153.108`
- Optional AAAA record:
- `joplin.okit.pl -> 2a01:4f9:c014:98f0::1`
- For normal Let's Encrypt through Nginx Proxy Manager, ports `80` and `443` must reach this VPS publicly.
- Public DNS should not point to the Tailscale IP if using standard Let's Encrypt HTTP validation.
Commands provided in handoff to start Joplin:
```sh
cd /home/dockeruser/docker/joplin-server
nano .env
# replace POSTGRES_PASSWORD
docker compose up -d
docker compose ps
docker compose logs -f app
```
Local tests on VPS after Joplin start:
```sh
curl -I http://127.0.0.1:22300
curl -I http://127.0.0.1:81
curl -I http://127.0.0.1:80
```
Public tests after DNS and Nginx Proxy Manager config:
```sh
dig joplin.okit.pl
curl -I https://joplin.okit.pl
```
## Unknown / needs clarification
- Whether Nginx Proxy Manager will preserve the manual generated-conf fix after future UI edits/restarts. The SQLite row is correct, but the active generated config did not update automatically during this fix.
- Whether `joplin.okit.pl` DNS has been created or fixed.
- Whether the optional AAAA record is intended.
- Whether Let's Encrypt certificate issuance has succeeded.
- Whether the laptop has a valid SSH key or password for `dockeruser@100.95.58.48`.
- Whether the Tailscale SSH host key for `ubuntu-4gb-hel1-1.tailedf7b1.ts.net` should be accepted on the laptop.

35
saturn/docs/networking.md Normal file
View file

@ -0,0 +1,35 @@
# Networking
## Description
This page documents the current known network position and port forwarding for the homelab.
## Current configuration
- The homelab is behind NAT.
- Port forwarding is configured as follows:
- External ports `80-81` forward to internal ports `4480-4481`
- External port `443` forwards to internal port `4443`
## Known facts
- NAT is present between the public internet and the homelab.
- Public HTTP/HTTPS traffic reaches the homelab through forwarded ports.
- External ports `80`, `81`, and `443` are known to be forwarded.
- Internal ports `4480`, `4481`, and `4443` are known forwarding targets.
## Unknown / needs clarification
- Router or firewall model.
- Whether the WAN IP is static, dynamic, or CGNAT.
- Internal IP address of the Raspberry Pi 5.
- Whether the Raspberry Pi 5 uses DHCP or static addressing.
- Exact mapping for external ports `80-81` to internal ports `4480-4481`:
- Whether `80` maps to `4480`.
- Whether `81` maps to `4481`.
- Protocols forwarded for each port: TCP, UDP, or both.
- Whether any other ports are forwarded.
- LAN subnet and gateway.
- DNS provider and DNS records.
- IPv6 availability or absence.
- Firewall rules on the Raspberry Pi 5.

65
saturn/docs/questions.md Normal file
View file

@ -0,0 +1,65 @@
# Unknowns and Clarification Questions
## Description
This page lists information that is missing or unclear from the current homelab documentation.
## Current configuration
The currently documented configuration is limited to:
- Raspberry Pi 5 as the main server.
- Docker, Portainer, and Nginx Proxy Manager as the core stack.
- NAT with forwarded ports:
- `80-81` to `4480-4481`
- `443` to `4443`
- Public access through Nginx Proxy Manager with Let's Encrypt HTTPS.
- Private access through Tailscale.
- Hetzner VPS handoff:
- Hostname: `ubuntu-4gb-hel1-1`
- Tailscale IP: `100.95.58.48`
- Public IPv4: `135.181.153.108`
- Public IPv6: `2a01:4f9:c014:98f0::1`
- Running container: `npm`
- Joplin files created but not running.
## Known facts
- The homelab is documented only from the known facts above.
- Anything not listed as known remains unconfirmed.
## Unknown / needs clarification
1. What operating system and version is running on the Raspberry Pi 5?
2. What is the Raspberry Pi 5 RAM size?
3. What storage devices are used, and where is persistent service data stored?
4. What is the Raspberry Pi 5 LAN IP address?
5. Is the Raspberry Pi 5 using DHCP or a static IP address?
6. What router or firewall performs NAT and port forwarding?
7. Is the WAN IP static, dynamic, or behind CGNAT?
8. Does external port `80` map to internal port `4480`, and does external port `81` map to internal port `4481`?
9. Are the forwarded ports TCP only, UDP only, or both?
10. Are any other ports forwarded?
11. What domain names or subdomains point to the homelab?
12. What are the Nginx Proxy Manager proxy hosts?
13. Which services are public, and which are private-only?
14. Is HTTP-to-HTTPS redirection enabled in Nginx Proxy Manager?
15. Are Nginx Proxy Manager access lists used?
16. How are Docker, Portainer, and Nginx Proxy Manager deployed?
17. Are Docker Compose files, Portainer stacks, or other manifests available?
18. What containers are currently running?
19. What Docker networks and volumes exist?
20. What is the Tailscale device name for the Raspberry Pi 5?
21. Does the Raspberry Pi 5 advertise Tailscale subnet routes?
22. Is the Raspberry Pi 5 configured as a Tailscale exit node?
23. Is Tailscale SSH enabled?
24. What backup system exists, if any?
25. What monitoring or alerting exists, if any?
26. Is the Hetzner VPS part of the homelab documentation scope, a separate system, or both?
27. What is the operating system version on `ubuntu-4gb-hel1-1`?
28. Is public Nginx Proxy Manager admin access on port `81` intentionally reachable on `135.181.153.108`?
29. Has DNS record `joplin.okit.pl -> 135.181.153.108` been created?
30. Has optional AAAA record `joplin.okit.pl -> 2a01:4f9:c014:98f0::1` been created?
31. Has `POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD` been changed before first Joplin production start?
32. Has the Nginx Proxy Manager proxy host for `joplin.okit.pl` been created?
33. Are ports `80` and `443` publicly reachable on the Hetzner VPS for Let's Encrypt HTTP validation?

51
saturn/docs/services.md Normal file
View file

@ -0,0 +1,51 @@
# Services
## Description
This page documents the currently known services in the homelab.
## Current configuration
Known Raspberry Pi 5 services:
- Portainer
- Nginx Proxy Manager
Known Hetzner VPS services:
- Nginx Proxy Manager
Known Hetzner VPS service files:
- Joplin Server
Known supporting platform:
- Docker
## Known facts
- Portainer is present in the homelab.
- Nginx Proxy Manager is present in the homelab.
- Public services are exposed through Nginx Proxy Manager using HTTPS.
- Private access is available through Tailscale.
- On the Hetzner VPS, Nginx Proxy Manager is running as container `npm`.
- On the Hetzner VPS, Joplin Server files have been created but the service is not running yet.
## Unknown / needs clarification
- Full list of running services and containers on the Raspberry Pi 5.
- Service names.
- Service purposes.
- Public or private exposure for each service.
- Internal ports for each service.
- External domains for each public service.
- Docker image names and versions.
- Data volume paths.
- Environment variables and secrets handling.
- Service dependencies.
- Restart policies.
- Health checks.
- Backup coverage for each service.
- Restore process for each service.
- Whether Joplin Server should be documented as part of the current homelab, as a VPS service, or both.

39
saturn/ollama_client.py Normal file
View file

@ -0,0 +1,39 @@
import json
import os
import urllib.error
import urllib.request
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434").rstrip("/")
def ask(prompt: str) -> str:
payload = {
"model": "deepseek-coder",
"messages": [{"role": "user", "content": prompt}],
"stream": False,
}
req = urllib.request.Request(
f"{OLLAMA_URL}/api/chat",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode("utf-8")
body = json.loads(raw)
return body["message"]["content"]
except urllib.error.HTTPError as exc:
return f"ERROR: HTTP {exc.code} {exc.reason}"
except urllib.error.URLError as exc:
return f"ERROR: {exc.reason}"
except json.JSONDecodeError as exc:
return f"ERROR: Invalid JSON response: {exc}"
except (KeyError, TypeError) as exc:
return f"ERROR: Invalid response format: {exc}"
except Exception as exc:
return f"ERROR: {exc}"
if __name__ == "__main__":
print(ask("Write docker-compose for nginx"))

47
saturn/start-aider.sh Executable file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE_FILE="$SCRIPT_DIR/codex_context.yaml"
DEFAULT_OLLAMA_API_BASE="http://100.100.231.104:11434"
AIDER_BIN="${AIDER_BIN:-$HOME/.local/bin/aider}"
usage() {
cat <<'EOF'
Usage: ./start-aider.sh [aider-args...]
Starts Aider from this repository with shared project context.
EOF
}
if (($# > 0)); then
case "$1" in
-h|--help)
usage
exit 0
;;
esac
fi
export OLLAMA_API_BASE="${OLLAMA_API_BASE:-$DEFAULT_OLLAMA_API_BASE}"
cd "$SCRIPT_DIR"
printf 'Loading %s\n' "$STATE_FILE"
if [[ ! -x "$AIDER_BIN" ]]; then
echo "Aider executable not found at $AIDER_BIN" >&2
exit 1
fi
if "$AIDER_BIN" --help 2>/dev/null | grep -q -- '--read FILE'; then
printf '%s\n' 'Starting Aider with codex_context.yaml attached as read-only shared context.'
exec "$AIDER_BIN" \
--model ollama/deepseek-coder:latest \
--read "$STATE_FILE" \
"$@"
fi
printf '%s\n' 'Aider does not support read-only context files in this environment.'
printf '%s\n' 'Ask Aider to read codex_context.yaml first, then continue with your task.'
exec "$AIDER_BIN" --model qwen2.5-coder:14b"$@"

124
saturn/start-codex.sh Executable file
View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE_FILE="$SCRIPT_DIR/codex_context.yaml"
INITIAL_INSTRUCTION="Before doing any task, read codex_context.yaml and treat it as shared project memory."
MEMORY_POLICY_PROMPT=$(cat <<'EOF'
You are an autonomous coding agent with persistent memory.
GOAL:
Continuously maintain and save a compressed, lossless SESSION_STATE to disk.
RULES:
1. After every meaningful change or decision:
- Update SESSION_STATE
- Save it to file: ./codex_context.yaml
2. SESSION_STATE must be:
- Lossless (no important info lost)
- Compressed (no fluff, structured)
FORMAT:
SESSION_STATE:
meta:
goal:
environment:
systems:
configs:
decisions:
todos:
issues:
3. Compression rules:
- No natural language fluff
- Use short keys
- Deduplicate
- Use IDs (S1, CFG1, etc.)
4. File operations:
- Always overwrite ./codex_context.yaml
- Ensure valid YAML
- Never delete info unless explicitly told
5. On startup:
- If ./codex_context.yaml exists -> load and use it as memory
6. Commands:
EXPORT STATE -> print file content only
IMPORT STATE -> load given YAML
7. Never ask for confirmation when saving context.
Act like a stateful system, not a stateless chat.
EOF
)
usage() {
cat <<'EOF'
Usage: ./start-codex.sh [--print-prompt] [codex-args...]
Starts Codex in this repository with the persistent-memory bootstrap prompt.
Options:
--print-prompt Print the generated startup prompt and exit.
-h, --help Show this help and exit.
Examples:
./start-codex.sh
./start-codex.sh --full-auto
./start-codex.sh --model gpt-5.4
./start-codex.sh --print-prompt
EOF
}
build_prompt() {
printf '%s\n\n' "$INITIAL_INSTRUCTION"
printf '%s\n\n' "$MEMORY_POLICY_PROMPT"
if [[ -f "$STATE_FILE" ]]; then
printf '%s\n\n' 'Load SESSION_STATE below as full context. Treat it as authoritative memory. Continue work accordingly.'
cat "$STATE_FILE"
printf '\n'
fi
}
main() {
local print_prompt=0
local -a codex_args=()
while (($# > 0)); do
case "$1" in
--print-prompt)
print_prompt=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
codex_args+=("$1")
shift
;;
esac
done
local prompt
prompt="$(build_prompt)"
if ((print_prompt)); then
printf '%s' "$prompt"
exit 0
fi
cd "$SCRIPT_DIR"
printf 'Loading %s\n' "$STATE_FILE"
exec codex --cd "$SCRIPT_DIR" "${codex_args[@]}" "$prompt"
}
main "$@"

8
saturn/update-context.md Normal file
View file

@ -0,0 +1,8 @@
# Shared Context Protocol
- Shared memory file: `codex_context.yaml`
- Codex and Aider must both read `codex_context.yaml` before starting work.
- After any meaningful work, update `codex_context.yaml`.
- Record decisions, todos, issues, and host-specific state.
- Keep the file compressed, structured, and lossless.
- Do not store secrets in `codex_context.yaml`.