Compare commits

..

10 Commits

Author SHA1 Message Date
scoot c13bcb3efa Migrate from raspen prototype to heron live VPS
- Replace raspen references with heron in AGENTS.md
- Update nginx config for live domains (qmoln.se)
- Switch SSL certs to Let's Encrypt paths
- Update WireGuard config with heron's keys and passive listen
- WireGuard direction: agge connects out to heron
- Remove old ssl volume mount, mount /etc/letsencrypt instead
2026-05-28 05:49:05 +02:00
scoot 915cab05b9 Add sudo convention to AGENTS.md 2026-05-24 16:51:07 +02:00
scoot abcf231b91 Add AGENTS.md with project context for AI assistants 2026-05-24 16:49:48 +02:00
scoot 55a8df8515 Gitignore session transcripts and vim swap files 2026-05-24 16:30:34 +02:00
scoot 279aaf37d9 Document .env deployment options 2026-05-24 16:15:59 +02:00
scoot a90002aefc Password management: .env.example pattern, gitignore, documentation 2026-05-24 16:09:27 +02:00
scoot a84b1de3fe Update DB passwords in .env 2026-05-24 15:53:57 +02:00
scoot 8b36c3c64b Replace symlink with actual file for git tracking 2026-05-24 15:03:09 +02:00
scoot 01efd7188a Proxy Gitea SSH port 2222 through raspen 2026-05-24 14:51:49 +02:00
scoot a8a4567fd3 VPN: wireguard sidecar, nginx upstreams to 10.0.0.2, backend ports bound to wg0, updated docs 2026-05-24 14:48:52 +02:00
12 changed files with 298 additions and 125 deletions
+3 -3
View File
@@ -1,16 +1,16 @@
# PostgreSQL (internal, for Nextcloud)
POSTGRES_DB=nextcloud
POSTGRES_USER=nextcloud
POSTGRES_PASSWORD=CHANGE_THIS_PASSWORD
POSTGRES_PASSWORD=CHANGE_ME
# Remote PostgreSQL (for external access)
PG_DB=remotedb
PG_USER=remoteuser
PG_PASSWORD=CHANGE_THIS_PASSWORD
PG_PASSWORD=CHANGE_ME
# Nextcloud
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=CHANGE_THIS_PASSWORD
NEXTCLOUD_ADMIN_PASSWORD=CHANGE_ME
TRUSTED_DOMAINS=nc.home localhost 127.0.0.1
# Gitea
+4
View File
@@ -0,0 +1,4 @@
.env
.session-*
session-*
*.swp
+167
View File
@@ -0,0 +1,167 @@
Project "selfhosted2", 23may2026
# Technical environment
Cross-development environment for prototyping a residential application server behind a VPS-style reverse proxy.
`hector`: The development machine. ASUS laptop running Linux Mint Mate. The machine on which this LLM session takes place (**Session ID:** ses_21ab382baffej8OpnTkrmaG5UA).
`agge`: Target machine acting as the 'Q Server'. Dell Optiplex mini PC running a headless Ubuntu Server. This is where the production apps reside. LAN IP `192.168.1.188`, WireGuard IP `10.0.0.2`.
`raspen`: Target machine acting as a VPS, with a reverse proxy routing all external traffic to applications on `agge` over an encrypted WireGuard tunnel. Raspberry Pi 5, running a headless Raspberry Pi OS Lite (64-bit). LAN IP `192.168.1.187`, WireGuard IP `10.0.0.1`.
The machines are connected through a dedicated local wifi hotspot, SSID `NR-24`. In the production deployment, `raspen` will be replaced by a cloud VPS with a public IP; the Docker compose and nginx configuration are designed to be portable as-is.
# Architecture
```
hector
└──→ raspen.home (192.168.1.187) ← nginx reverse proxy (Docker)
│ wireguard sidecar (Docker)
│ wg0: 10.0.0.1/30
│ ────────── WireGuard tunnel ──────────
│ wg0: 10.0.0.2/30
│ Docker: Gitea, Nextcloud, PostgreSQL
└──→ agge (192.168.1.188)
```
Raspen is the single entry point for all traffic. Nginx terminates SSL and proxies requests through the encrypted WireGuard tunnel to agge. No services are exposed on the plain LAN — Docker containers on agge bind exclusively to `10.0.0.2`.
# Network
Hosts file on `hector` (`/etc/hosts`):
```
192.168.1.187 raspen.home nc.home git.home pg.home
192.168.1.188 agge
```
Only `raspen.home`'s IP is published to clients. `agge` has no direct domain mapping in the hosts file (it is addressed only by its WireGuard IP).
## WireGuard VPN
| Peer | Interface | IP | Port |
|------|-----------|-----|------|
| raspen | wg0 | 10.0.0.1/30 | ephemeral (outbound) |
| agge | wg0 | 10.0.0.2/30 | 51820/udp |
Raspen runs WireGuard inside a Docker sidecar container (`linuxserver/wireguard`). Agge runs WireGuard as a native systemd service (`wg-quick@wg0`). UFW on agge allows UDP 51820 inbound.
Raspen's config (repo: `vps/wireguard/wg_confs/wg0.conf`):
- Connects to `192.168.1.188:51820`
- Keepalive 25s
- Only routes `10.0.0.2/32` through the tunnel
Agge's config (`/etc/wireguard/wg0.conf`):
- Listens on port 51820
- Only accepts traffic from `10.0.0.1/32`
# Applications
## VPS (raspen) — Docker stack
Location: `vps/docker-compose.yml`
Two containers sharing a network namespace:
- **wireguard**: Creates the VPN tunnel. Publishes ports 80, 443, 5432, 2222, and 51820/udp.
- **nginx**: Reverse proxy. Shares wireguard's network namespace (`network_mode: "service:wireguard"`). Proxies to agge via WireGuard IPs.
Nginx config (`vps/nginx/conf.d/default.conf`):
- `nc.home:443``10.0.0.2:8080` (Nextcloud)
- `git.home:443``10.0.0.2:3000` (Gitea)
- `raspen.home:80/443` → static page
- HTTP redirects to HTTPS
- Self-signed SSL certs at `vps/ssl/`
TCP stream proxies (`vps/nginx/stream.d/`):
- `pg.home:5432``10.0.0.2:5432` (PostgreSQL)
- `git.home:2222``10.0.0.2:2222` (Gitea SSH)
## Backend (agge) — Docker stack
Location: `backend/docker-compose.yml`
Four containers on an internal `backend` bridge network:
| Container | Image | Ports (bound to 10.0.0.2) | Purpose |
|-----------|-------|---------------------------|---------|
| postgres | postgres:16-alpine | — (internal) | Database for Nextcloud |
| nextcloud | nextcloud:latest | 8080:80 | File sync & share |
| gitea | gitea/gitea:latest | 3000:3000 (HTTP), 2222:22 (SSH) | Git hosting |
| postgres_remote | postgres:16-alpine | 5432:5432 | Remote-access DB |
All published ports bind to `10.0.0.2` only, making them inaccessible over the plain LAN — traffic must arrive via the WireGuard tunnel.
Environment variables are loaded from `.env` (repo root).
# Development
Working directory: `/home/allan/Work/selfhosted2`, a Git repo hosted on Gitea at `agge`. Two remote URLs are configured:
- `https://git.home/scoot/selfhosted2.git` (via raspen proxy — useful for read-only with self-signed cert)
- `ssh://git@git.home:2222/scoot/selfhosted2.git` (via raspen stream proxy — used for push)
Remote access:
- `raspen`: `rasput@192.168.1.187`
- `agge`: `tebarbi@192.168.1.188`
opencode configuration (`opencode.json`):
- Permission model asks before executing bash or accessing external directories.
## Password management
Secrets are managed through a `.env.example` pattern:
- **`.env.example`** (tracked in git) — template with placeholder values documenting all required variables.
- **`.env`** (gitignored) — the actual secrets file on hector. A copy lives at `~/selfhosted2/.env` on agge.
- **Docker volumes** — runtime copies of passwords baked into PostgreSQL and Nextcloud config on first deploy.
Password sources:
| Variable | User | Where it's used | Where it's stored |
|----------|------|-----------------|-------------------|
| `POSTGRES_PASSWORD` | `nextcloud` | Internal PostgreSQL (DB owner) | `.env`, Docker volume |
| — | `oc_admin` | Nextcloud app DB user | Nextcloud config.php |
| `PG_PASSWORD` | `remoteuser` | Remote PostgreSQL | `.env`, Docker volume |
| `NEXTCLOUD_ADMIN_PASSWORD` | `admin` | Nextcloud web login | `.env`, personal password manager |
To rotate a password:
1. Generate: `openssl rand -base64 18`
2. Update `.env` and sync to agge
3. Run `ALTER USER <user> WITH PASSWORD '<new>';` in the appropriate PostgreSQL container
4. If rotating the Nextcloud app user, also update `config.php` via `sed`
5. Restart the service if needed
Do not commit `.env` to git. The `.env.example` template is the only password-related file that belongs in the repo.
### Deploy-time only
The `.env` file is read only during `docker compose up` (container create/recreate). At runtime, secrets live in Docker volumes and container env vars. Options for handling `.env` on the target machine:
| Option | Security | Convenience |
|--------|----------|-------------|
| **Keep with `chmod 600`** (current) | Root on agge can still read it — but root already owns the volumes | Compose works immediately |
| **Remove after deploy** | Minimal disk exposure | Must scp from hector before any `docker compose down && up` |
| **Deploy script** (`deploy.sh`) | `.env` lives only on hector, pushed transiently during deploy | Single command, cleanest for production |
For the prototype, `chmod 600` is fine. For a production VPS, a deploy script is better — the `.env` never sits on disk permanently.
# Session history
**Session ses_21ab382baffej8OpnTkrmaG5UA** — initial prototype deployment: Docker files written, containers configured, applications deployed and verified.
**Post-migration recovery** (this session):
1. Hector was rebuilt from Omarchy Linux to Linux Mint Mate, losing the local work directory and self-signed certs.
2. The remote Gitea repo on agge was intact; the local repo was recovered via `GIT_SSL_NO_VERIFY=1 git fetch` over HTTPS (self-signed cert).
3. `/etc/hosts` was corrected — all domains now point to raspen (192.168.1.187) as the single entry point.
4. A WireGuard VPN was established between raspen (Docker sidecar) and agge (native systemd service).
5. Nginx upstream targets were changed from `192.168.1.188` to `10.0.0.2`.
6. Docker port bindings on agge were restricted to `10.0.0.2` to enforce tunnel-only access.
7. UFW on agge was configured to allow WireGuard UDP 51820.
8. Gitea SSH port 2222 was added to nginx's TCP stream proxies on raspen, enabling git push over SSH through the VPN tunnel.
9. PostgreSQL passwords were rotated to strong values; `.env` replaced with `.env.example` pattern and gitignored; password management documented.
All services confirmed operational: Gitea (HTTP 200), Nextcloud (HTTP 302), PostgreSQL (port open), and static page (HTTP 200).
+56
View File
@@ -0,0 +1,56 @@
# selfhosted2
## Machines
| Name | Role | OS | LAN IP | WG IP | SSH user |
|------|------|----|--------|-------|----------|
| hector | Dev machine (this one) | Linux Mint Mate | — | — | — |
| agge | Backend server | Ubuntu Server | 192.168.1.188 | 10.0.0.2 | tebarbi |
| heron | VPS / reverse proxy | Ubuntu Server | 82.197.73.238 | 10.0.0.1 | qbert |
## Architecture
All traffic goes through **heron** (single entry point, public IP `82.197.73.238`):
- nginx in Docker (`vps/docker-compose.yml`) terminates SSL (Let's Encrypt) and reverse proxies
- WireGuard sidecar container encrypts traffic to agge
- agge connects *out* to heron (agge is behind residential NAT, heron listens passively)
- All backend services bind to `10.0.0.2` only (tunnel-only access)
## Services
| Domain | Service | Backend | Via heron |
|--------|---------|---------|-----------|
| git.qmoln.se | Gitea | agge:10.0.0.2:3000 (HTTP), :2222 (SSH) | HTTPS :443, TCP :2222 |
| nc.qmoln.se | Nextcloud | agge:10.0.0.2:8080 | HTTPS :443 |
| pg.qmoln.se | PostgreSQL | agge:10.0.0.2:5432 | TCP :5432 |
| qmoln.se | Static page | Served directly by nginx | HTTP/HTTPS |
## Repo structure
- `backend/docker-compose.yml` — agge stack (postgres, nextcloud, gitea, postgres_remote)
- `vps/docker-compose.yml` — heron stack (wireguard sidecar + nginx)
- `vps/nginx/conf.d/` — HTTP/HTTPS proxy configs
- `vps/nginx/stream.d/` — TCP stream proxy configs (postgres, gitea ssh)
- `vps/wireguard/wg_confs/` — WireGuard tunnel config
- `.env.example` — template for secrets (real `.env` is gitignored)
## WireGuard keys
- agge public key: `02k4BaH3iZTQnPZe7zifcaS9n8xxrwCLyIOLTBWLdgk=`
- heron public key: `4BtJlUWOzBtvrRu3llQbD0GPvlXgTwLq79iBth3uOSo=`
- agge connects out to heron at `82.197.73.238:51820`
- heron's WG private key is in `vps/wireguard/wg_confs/wg0.conf`
## Git remote (via tunnel — only reachable when tunnel is up)
```
ssh://git@git.qmoln.se:2222/scoot/selfhosted2.git
```
## Conventions
- The machines `agge` and `heron` require `sudo` for most commands. Do **not** attempt to execute commands requiring sudo via SSH — present the command to the user and let them run it manually.
## Password management
See `.env.example` for required variables. Real `.env` lives on hector and a copy on agge (`chmod 600`). To rotate: generate with `openssl rand -base64 18`, update `.env`, run `ALTER USER` in the container.
+4 -4
View File
@@ -29,7 +29,7 @@ services:
depends_on:
- postgres
ports:
- "8080:80"
- "10.0.0.2:8080:80"
networks:
- backend
@@ -52,8 +52,8 @@ services:
- GITEA__server__HTTP_PORT=3000
- GITEA__security__INSTALL_LOCK=true
ports:
- "3000:3000"
- "2222:22"
- "10.0.0.2:3000:3000"
- "10.0.0.2:2222:22"
networks:
- backend
@@ -67,7 +67,7 @@ services:
- POSTGRES_USER=${PG_USER}
- POSTGRES_PASSWORD=${PG_PASSWORD}
ports:
- "5432:5432"
- "10.0.0.2:5432:5432"
networks:
- backend
+1
View File
@@ -1,5 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["AGENTS.md"],
"permission": {
"bash": "ask",
"external_directory": "ask"
-83
View File
@@ -1,83 +0,0 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
networks:
- backend
nextcloud:
image: nextcloud:latest
restart: unless-stopped
volumes:
- nextcloud_data:/var/www/html/data
- nextcloud_config:/var/www/html/config
environment:
- POSTGRES_HOST=postgres
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER}
- NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD}
- TRUSTED_DOMAINS=${TRUSTED_DOMAINS}
- OVERWRITEPROTOCOL=https
depends_on:
- postgres
ports:
- "8080:80"
networks:
- backend
gitea:
image: gitea/gitea:latest
restart: unless-stopped
volumes:
- gitea_data:/data
expose:
- "3000"
- "2222"
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__server__PROTOCOL=http
- GITEA__server__DOMAIN=${GITEA_DOMAIN}
- GITEA__server__ROOT_URL=https://${GITEA_DOMAIN}/
- GITEA__server__SSH_PORT=2222
- GITEA__server__HTTP_PORT=3000
- GITEA__security__INSTALL_LOCK=true
ports:
- "3000:3000"
- "2222:22"
networks:
- backend
postgres_remote:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres_remote_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=${PG_DB}
- POSTGRES_USER=${PG_USER}
- POSTGRES_PASSWORD=${PG_PASSWORD}
ports:
- "5432:5432"
networks:
- backend
networks:
backend:
driver: bridge
volumes:
postgres_data:
postgres_remote_data:
nextcloud_data:
nextcloud_config:
gitea_data:
+24 -13
View File
@@ -1,20 +1,31 @@
services:
nginx:
image: nginx:alpine
restart: unless-stopped
wireguard:
image: linuxserver/wireguard
cap_add:
- NET_ADMIN
- NET_RAW
- SYS_MODULE
sysctls:
net.ipv4.conf.all.src_valid_mark: "1"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/stream.d:/etc/nginx/stream.d:ro
- ./ssl:/etc/nginx/certs:ro
- ./html:/usr/share/nginx/html:ro
- ./wireguard:/config
ports:
- "80:80"
- "443:443"
- "5432:5432"
networks:
- frontend
- "2222:2222"
- "51820:51820/udp"
restart: unless-stopped
networks:
frontend:
driver: bridge
nginx:
image: nginx:alpine
network_mode: "service:wireguard"
depends_on:
- wireguard
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/stream.d:/etc/nginx/stream.d:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
+16 -16
View File
@@ -1,26 +1,26 @@
upstream nextcloud {
server 192.168.1.188:8080;
server 10.0.0.2:8080;
}
upstream gitea {
server 192.168.1.188:3000;
server 10.0.0.2:3000;
}
server {
listen 80;
server_name nc.home;
return 301 https://nc.home$request_uri;
server_name nc.qmoln.se;
return 301 https://nc.qmoln.se$request_uri;
}
server {
listen 80;
server_name git.home;
return 301 https://git.home$request_uri;
server_name git.qmoln.se;
return 301 https://git.qmoln.se$request_uri;
}
server {
listen 80;
server_name raspen.home;
server_name qmoln.se;
root /usr/share/nginx/html;
index index.html;
@@ -37,10 +37,10 @@ server {
server {
listen 443 ssl;
server_name nc.home;
server_name nc.qmoln.se;
ssl_certificate /etc/nginx/certs/ssl.crt;
ssl_certificate_key /etc/nginx/certs/ssl.key;
ssl_certificate /etc/letsencrypt/live/qmoln.se/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/qmoln.se/privkey.pem;
client_max_body_size 10G;
client_body_timeout 3600s;
@@ -63,10 +63,10 @@ server {
server {
listen 443 ssl;
server_name git.home;
server_name git.qmoln.se;
ssl_certificate /etc/nginx/certs/ssl.crt;
ssl_certificate_key /etc/nginx/certs/ssl.key;
ssl_certificate /etc/letsencrypt/live/qmoln.se/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/qmoln.se/privkey.pem;
location / {
proxy_pass http://gitea;
@@ -82,10 +82,10 @@ server {
server {
listen 443 ssl;
server_name raspen.home;
server_name qmoln.se;
ssl_certificate /etc/nginx/certs/ssl.crt;
ssl_certificate_key /etc/nginx/certs/ssl.key;
ssl_certificate /etc/letsencrypt/live/qmoln.se/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/qmoln.se/privkey.pem;
root /usr/share/nginx/html;
index index.html;
+8
View File
@@ -0,0 +1,8 @@
upstream gitea_ssh {
server 10.0.0.2:2222;
}
server {
listen 2222;
proxy_pass gitea_ssh;
}
+1 -1
View File
@@ -1,5 +1,5 @@
upstream postgres_backend {
server 192.168.1.188:5432;
server 10.0.0.2:5432;
}
server {
+9
View File
@@ -0,0 +1,9 @@
[Interface]
Address = 10.0.0.1/30
PrivateKey = EFp3S6XsMQEEM8o6KJBNv5gybTfS28xnO/XwWSLue2k=
ListenPort = 51820
[Peer]
PublicKey = 02k4BaH3iZTQnPZe7zifcaS9n8xxrwCLyIOLTBWLdgk=
AllowedIPs = 10.0.0.2/32
PersistentKeepalive = 25