Panama learns what a server is: from a root login to running containers

A machine's role is now the interview's first question and the one answer
Panama records. Servers get the same shell minus the screen: core packages,
nvm, Bun, Claude Code and Codex (desktops get Codex too), linger, rootless
ports from 80, firewalld, the nginx-bridge network, and a nightly image
updater that replaced watchtower for cause.

server/containers/ carries junior's 23 compose services -- secrets moved to
per-machine .env files that never enter this public repo, every transformed
compose proven to render byte-identical to what is live. 'panama server'
enables, disables and relinks them; nothing here restarts a running service.
'boot --server' walks a fresh VPS from its root login to a normal install.

Five new contracts pin the secrets rule, the catalog's shape, panama-server's
behavior, the role plumbing, and the dotfile classification.

Claude-Session: https://claude.ai/code/session_01NU5JGiN3JfzqrLQB6wmJ1E
This commit is contained in:
Gabriel Brown
2026-08-25 23:11:49 -04:00
parent 9b338608ef
commit f33da41cc6
93 changed files with 4735 additions and 247 deletions
+82
View File
@@ -0,0 +1,82 @@
# The server role
What a Panama machine runs when the interview answered `server`: the same
shell environment as every other machine, minus everything that needs a
screen, plus rootless podman compose services managed as systemd user units.
```
server/
containers/ One directory per service: compose.yml, the
podman-<name>.service user unit, and an .env.example
naming what the service needs told
scripts/ update-containers, the nightly image updater
systemd/ The units behind it, linked by setup-server
```
## The contract with ~/Server
The repository carries the definitions; the machine carries the state.
`panama server enable <Name>` creates `~/Server/<Name>/` as a **real
directory** and symlinks only `compose.yml` into it; the unit symlinks into
`~/.config/systemd/user/`. The `.env` (seeded once from `.env.example`, then
yours) and the bind-mounted `data/` live in `~/Server/<Name>/` and are never
inside the checkout — so nothing git can do, `clean -fdx` included, can reach
a database.
Because of that split, compose files must reference secrets as `${VAR}`
interpolations resolved from the `.env` beside them, never inline.
`tests/server/compose-secrets-contract` fails the suite when a tracked file
under `server/` carries anything that looks like a secret: this repository is
public, and the gitignore is a seatbelt, not the brakes.
Placeholders in `.env.example` are spelled `CHANGE_ME`, exactly — that token
is what `panama server enable` refuses to start on.
## Conventions
- Service directories are TitleCase, matching `~/Server` as it has always
looked: `server/containers/Gitea` becomes `~/Server/Gitea`.
- Bind mounts live under `./data/` in new services. Imported services keep
the volume path they were running with until their cutover, because the
repo copy must render identically to the live one — renames happen with
the service stopped, not in git.
- Only 80, 443 and 81 are open (setup-server's doing). Everything else is
reverse-proxied over the `nginx-bridge` network by container name, with no
published ports of its own. A service that truly needs another host port
says so in a README in its directory, and the port is opened by hand.
- Every compose joins `nginx-bridge` (external; created by setup-server).
## Enabling, updating, cutting over
```sh
panama server list # the catalog, and what this machine runs
panama server enable Gitea # link, seed .env, enable --now
panama server status # is what is enabled actually up
panama server disable Gitea # stop and unlink; data and .env stay
```
`panama update` relinks and reloads but **never restarts a running
service** — it names the ones whose definitions changed and leaves the
restart to you.
Moving a service that already runs from hand-managed files onto the repo's
(the cutover): stop the unit, standardize the volume directory to `data/`
(updating the repo compose to match), then `panama server enable <Name>`
it backs up the hand-written files as `*.pre-panama` and links the tracked
ones — and verify it came back up. One service at a time, quiet hours.
## Adding a service
Add `server/containers/<Name>/` with the three files, modelled on any
existing service. The unit is the standard oneshot `podman compose up -d`
shape (`WorkingDirectory=%h/Server/<Name>`); the shape contract pins the
details. Secrets go in `.env.example` as `KEY=CHANGE_ME` lines.
## Nightly updates
`server/scripts/update-containers` pulls images and restarts changed
services through their units, nightly at midnight via `podman-update.timer`.
It replaced watchtower after watchtower recreated a container outside its
compose pod and took Gitea down for three days — the script's header carries
the full story, plus the SKIP list for services that must only ever be
updated by hand (postgresql, authentik).
+44
View File
@@ -0,0 +1,44 @@
# Adminer — database admin UI. VPS (ROOTLESS PODMAN), added 2026-08-12.
#
# On the home server this shipped alongside a MySQL container in the same compose
# file. Here it is standalone and defaults to the shared PostgreSQL instance, which
# is where the 14 databases now live (authentik, infisical_db, n8n, npm, the payload_*
# and convex databases, usesend, lashaddict-payload, spoon_convex...).
#
# MySQL stays on the home server — nothing on the VPS uses it.
#
# It can still reach any other database reachable on nginx-bridge; ADMINER_DEFAULT_SERVER
# only pre-fills the server field on the login form.
#
# ⚠️ SECURITY: Adminer has NO authentication of its own — it is a login form that
# forwards credentials to whichever database you name. Publishing it means exposing a
# database login prompt to the internet. Put it behind authentik forward-auth in NPM
# (same pattern as sonarr/prowlarr on the home server), or restrict it to the LAN by
# giving it only a UniFi record and no Cloudflare record.
#
# Rootless adaptations: no bind mounts (stateless, so no :Z needed), no published
# ports — NPM proxies to http://adminer:8080 over nginx-bridge.
#
# NPM: adminer.gbrown.org -> http://adminer:8080
networks:
nginx-bridge:
external: true
services:
adminer:
image: docker.io/library/adminer:latest
container_name: adminer
hostname: adminer
domainname: adminer.gbrown.org
networks:
- nginx-bridge
environment:
- TZ=America/New_York
# Pre-fills the server field; any nginx-bridge host can still be typed in.
- ADMINER_DEFAULT_SERVER=postgresql
- ADMINER_DESIGN=dracula
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Adminer
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Adminer
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+27
View File
@@ -0,0 +1,27 @@
networks:
nginx-bridge:
external: true # ALWAYS external; see AGENTS.md §5
services:
agentchat:
image: git.gbrown.org/gib/agentchat:latest
container_name: agentchat
hostname: agentchat
domainname: agentchat.gbrown.org
networks: ['nginx-bridge']
ports:
# Loopback only: lets this host's own agent reach the hub at
# http://127.0.0.1:8080 without going through NPM - the LAN access
# list there blocks the VPS's public IP. Unreachable from elsewhere.
- '127.0.0.1:8080:8080'
environment:
- TZ=America/New_York
- AGENTCHAT_DB=/data/agentchat.db
volumes:
- ./data:/data:z # lowercase :z — watchtower recreates DROP the relabel flag, and :Z-category files would lock the new container out (learned 2026-08-13); :z keeps them accessible
labels: ['com.centurylinklabs.watchtower.enable=true']
restart: unless-stopped
healthcheck:
test: wget -qO- http://localhost:8080/healthz || exit 1
interval: 30s
start_period: 10s
@@ -0,0 +1,24 @@
# TEMPLATE for a rootless user unit.
# Install to ~/.config/systemd/user/podman-<name>.service then:
# systemctl --user daemon-reload && systemctl --user enable --now podman-<name>.service
# Requires linger: sudo loginctl enable-linger $USER
[Unit]
Description=Podman Compose: agentchat
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Agentchat
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+20
View File
@@ -0,0 +1,20 @@
PG_PASS=CHANGE_ME
AUTHENTIK_SECRET_KEY=CHANGE_ME
AUTHENTIK_ERROR_REPORTING__ENABLED=true
COMPOSE_PORT_HTTP=9000
COMPOSE_PORT_HTTPS=9443
AUTHENTIK_TAG=2026.2.2
# SMTP Host Emails are sent to
AUTHENTIK_EMAIL__HOST=smtp.mail.me.com
AUTHENTIK_EMAIL__PORT=587
# Optionally authenticate (don't add quotation marks to your password)
AUTHENTIK_EMAIL__USERNAME=CHANGE_ME
AUTHENTIK_EMAIL__PASSWORD=CHANGE_ME
# Use StartTLS
AUTHENTIK_EMAIL__USE_TLS=true
# Use SSL
AUTHENTIK_EMAIL__USE_SSL=false
AUTHENTIK_EMAIL__TIMEOUT=10
# Email address authentik will send from, should have a correct @domain
AUTHENTIK_EMAIL__FROM=[email protected]
+93
View File
@@ -0,0 +1,93 @@
# Authentik — VPS (ROOTLESS PODMAN) copy of the home server's auth stack.
#
# This is a 1:1 copy of ~/Server/auth on server.gib, migrated 2026-08-11. The home
# instance is still running and authoritative; this one has an independent database
# restored from a dump taken at migration time, so THE TWO DIVERGE FROM THAT MOMENT ON.
# Do not treat this as a hot standby -- it is a rehearsal/cutover target.
#
# Differences from the home Docker/root version, and why:
#
# :z on ./volumes/media (LOWERCASE, shared)
# Both server and worker mount this same path. `:Z` assigns a PRIVATE SELinux MCS
# category per container, so the second container to start would relabel it and
# lock the first one out. Shared mounts must use `:z`. The per-container mounts
# (server/custom-templates, worker/custom-templates, worker/certs) are exclusive
# and correctly use `:Z`.
#
# No redis anywhere
# authentik 2026.x dropped the Redis dependency (Postgres-backed now). The home
# stack has no redis container and no AUTHENTIK_REDIS__* vars either -- verified,
# not assumed. Do not "helpfully" add one.
#
# Postgres is the shared VPS instance
# AUTHENTIK_POSTGRESQL__HOST=postgresql resolves over nginx-bridge to the same
# container N8n and NPM use. Role + database `authentik` were created there with
# the same PG_PASS as home, so .env needed no edits.
#
# .env is copied verbatim from home and contains AUTHENTIK_SECRET_KEY. That key MUST
# match the one the database was encrypted with, or tokens and stored secrets break.
# It is mode 600 -- never print it, never commit it.
#
# NOT YET SERVING auth.gbrown.org. That DNS record still points home. Cutover = create
# an explicit auth.gbrown.org record pointing at this VPS (an explicit record overrides
# the *.gbrown.org wildcard) plus an NPM proxy host to http://authentik-server:9000.
networks:
nginx-bridge:
external: true
services:
server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
container_name: authentik-server
hostname: authentik-server
domainname: auth.gbrown.org
networks:
- nginx-bridge
command: server
# Bound to the WireGuard address ONLY -- never listens on eth0, so this is not
# reachable from the internet regardless of firewall state. Home's NPM uses it for
# forward-auth (14 proxy hosts point at http://192.168.2.2:9000/outpost.goauthentik.io).
# Do NOT change this to a bare "9000:9000"; that would expose it on the public
# interface. Port 9443 is deliberately NOT published -- portainer already uses it.
ports:
- "192.168.2.2:9000:9000"
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
TZ: America/New_York
env_file:
- .env
volumes:
- ./volumes/server/custom-templates:/templates:Z
- ./volumes/media:/data/media:z
labels:
com.centurylinklabs.watchtower.enable: "true"
restart: unless-stopped
tty: true
worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
container_name: authentik-worker
hostname: authentik-worker
networks:
- nginx-bridge
command: worker
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
TZ: America/New_York
env_file:
- .env
volumes:
- ./volumes/media:/data/media:z
- ./volumes/worker/certs:/certs:Z
- ./volumes/worker/custom-templates:/templates:Z
labels:
com.centurylinklabs.watchtower.enable: "true"
restart: unless-stopped
tty: true
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Authentik
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Authentik
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+70
View File
@@ -0,0 +1,70 @@
# Beszel — lightweight host + container monitoring, VPS (ROOTLESS PODMAN) edition.
#
# Adapted from the home server's Docker/root version. Differences and why:
#
# docker.sock -> /run/user/1000/podman/podman.sock
# Rootless podman's API socket. It exposes ONLY rootless containers, which is
# exactly the set we want reported. No :Z on the socket mount -- relabeling the
# live socket breaks podman. See AGENTS.md §6.
#
# security_opt: label:disable on the agent
# Required under SELinux Enforcing to read that socket, which systemd recreates
# each boot as user_tmp_t. Same treatment as portainer/uptime.
#
# EXTRA_FILESYSTEMS=/boot dropped
# The home server has /boot on its own 974M partition. This VPS does not --
# /dev/sda1 is the whole root fs and /boot/efi is a 64M ESP not worth alerting on.
#
# No hostname/domainname on the agent
# Host networking forbids them, same as on the home server.
#
# The hub generates its keypair on first start at ./volumes/hub/id_ed25519.pub.
# That public key is what goes in the agent's KEY below.
networks:
nginx-bridge:
external: true
services:
beszel:
image: docker.io/henrygd/beszel:latest
container_name: beszel
hostname: beszel
domainname: beszel.gibbyb.com
networks: ['nginx-bridge']
# No published port: NPM proxies to http://beszel:8090 over nginx-bridge.
environment:
- TZ=America/New_York
# Lets the hub reach a host-network agent. Under rootless podman this resolves to
# the nginx-bridge gateway (172.18.0.1). Add the VPS in the hub UI with host
# "host.docker.internal", port 45876.
extra_hosts:
- "host.docker.internal:host-gateway"
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/hub:/beszel_data:Z
tty: true
stdin_open: true
restart: unless-stopped
beszel-agent:
image: docker.io/henrygd/beszel-agent:latest
container_name: beszel-agent
network_mode: host
environment:
- TZ=America/New_York
- PORT=45876
# This hub's public key. Newer Beszel writes only ./volumes/hub/id_ed25519 (private);
# derive the public half with: ssh-keygen -y -f volumes/hub/id_ed25519
# It is a PUBLIC key -- safe to keep in this file.
- KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBWNqxKGRltqXJZg+wlDOtICeLo19ftQ6P3P8+uJg8y
security_opt: ['label:disable']
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/agent:/var/lib/beszel-agent:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Beszel
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Beszel
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+5
View File
@@ -0,0 +1,5 @@
NEXTAUTH_SECRET=CHANGE_ME
CALENDSO_ENCRYPTION_KEY=CHANGE_ME
DATABASE_URL=CHANGE_ME
DATABASE_DIRECT_URL=CHANGE_ME
EMAIL_SERVER_PASSWORD=CHANGE_ME
+60
View File
@@ -0,0 +1,60 @@
# Cal.com — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12.
#
# ⚠️ NOT STARTED. No systemd unit is enabled. It has never run on either machine, so
# there is no data to migrate. Bring it up with:
# systemctl --user enable --now podman-calcom.service
#
# BEFORE FIRST START, create its role and database on the SHARED postgres (this stack
# no longer ships its own):
# podman exec -i postgresql psql -U npm <<'SQL'
# CREATE ROLE calcom LOGIN PASSWORD '<see this service .env on the machine>';
# CREATE DATABASE calcom OWNER calcom;
# SQL
# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql.
#
# Also unset: EMAIL_SERVER_HOST/USER/PASSWORD are still CHANGE_ME. Cal.com will run,
# but booking confirmations and invitations will not send.
#
# Note Cal.com runs database migrations on first boot and can take several minutes to
# become responsive. The unit allows 900s for this.
#
# Rootless adaptations from the home version:
# - Dropped its private postgres:16-alpine; uses the shared instance (AGENTS.md §2).
# - /etc/localtime mount removed in favour of TZ (AGENTS.md §8).
# - No bind mounts remain, so no :Z is needed.
#
# NPM: proxy cal.gbrown.org -> http://calcom:3000
networks:
nginx-bridge:
external: true
services:
calcom:
image: docker.io/calcom/cal.com:latest
container_name: calcom
hostname: calcom
domainname: cal.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- NEXT_PUBLIC_WEBAPP_URL=https://cal.gbrown.org
- NEXT_PUBLIC_WEBSITE_URL=https://cal.gbrown.org
- NEXTAUTH_URL=https://cal.gbrown.org/api/auth
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- CALENDSO_ENCRYPTION_KEY=${CALENDSO_ENCRYPTION_KEY}
# Shared postgres, not a private container
- DATABASE_URL=${DATABASE_URL}
- DATABASE_DIRECT_URL=${DATABASE_DIRECT_URL}
- NEXT_PUBLIC_LICENSE_CONSENT=agree
- ALLOWED_HOSTNAMES="cal.gbrown.org"
- [email protected]
- EMAIL_SERVER_HOST=CHANGE_ME
- EMAIL_SERVER_PORT=587
- EMAIL_SERVER_USER=CHANGE_ME
- EMAIL_SERVER_PASSWORD=${EMAIL_SERVER_PASSWORD}
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Cal.com
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/CalCom
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=900
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
@@ -0,0 +1,59 @@
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
# Keep this file up-to-date when you add new variables to \`.env\`.
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
## Next App ##
NODE_ENV=production
SENTRY_AUTH_TOKEN=CHANGE_ME
PAYLOAD_SECRET=CHANGE_ME
PAYLOAD_DB_URL=CHANGE_ME
NEXT_PUBLIC_SITE_URL=https://convexmonorepo.gbrown.org
NEXT_PUBLIC_CONVEX_URL=https://api.convexmonorepo.gbrown.org # convex-backend:3210
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.gbrown.org
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/3
NEXT_PUBLIC_SENTRY_URL=https://sentry.gbrown.org
NEXT_PUBLIC_SENTRY_ORG=sentry
NEXT_PUBLIC_SENTRY_PROJECT_NAME=convexmonorepo-nextjs
## Convex ##
CONVEX_SELF_HOSTED_URL=https://api.convexmonorepo.gbrown.org # convex-backend:3210
CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME
# Convex Auth
CONVEX_SITE_URL=https://convexmonorepo.gbrown.org # convex-backend:3211
#CONVEX_SITE_URL=https://convexmonorepo.gbrown.org # convex-backend:3211
USESEND_API_KEY=CHANGE_ME
USESEND_URL=https://usesend.example.com
USESEND_FROM_EMAIL='Convex Admin <[email protected]>'
AUTH_AUTHENTIK_ID=CHANGE_ME
AUTH_AUTHENTIK_SECRET=CHANGE_ME
AUTH_AUTHENTIK_ISSUER=https://auth.gbrown.org/application/o/convexmonorepo/
## Docker Compose Variables for Next App ##
NETWORK=nginx-bridge
NEXT_CONTAINER_NAME=convexmonorepo-next
NEXT_DOMAIN=convexmonorepo.gbrown.org
NEXT_PORT=3000
## Docker Compose Variables for Self hosted Convex ##
BACKEND_TAG=latest
DASHBOARD_TAG=latest
BACKEND_CONTAINER_NAME=convexmonorepo-backend
DASHBOARD_CONTAINER_NAME=convexmonorepo-dashboard
BACKEND_DOMAIN=convex.convexmonorepo.gbrown.org
DASHBOARD_DOMAIN=dashboard.convexmonorepo.gbrown.org
INSTANCE_NAME=convex
#INSTANCE_SECRET=
CONVEX_CLOUD_ORIGIN=https://api.convexmonorepo.gbrown.org
CONVEX_SITE_ORIGIN=https://convex.convexmonorepo.gbrown.org
NEXT_PUBLIC_DEPLOYMENT_URL=https://api.convexmonorepo.gbrown.org
DISABLE_BEACON=true
REDACT_LOGS_TO_CLIENT=true
DO_NOT_REQUIRE_SSL=true
#POSTGRES_URL= #postgresql://user:password@host:5432/db_name
#BACKEND_PORT=
#DASHBOARD_PORT
#SITE_PROXY_PORT=
#ACTIONS_USER_TIMEOUT_SECS=
#RUST_LOG=
#RUST_BACKTRACE=
@@ -0,0 +1,78 @@
networks:
nginx-bridge: # Change to network you plan to use
external: true
services:
convexmonorepo-next:
image: git.gbrown.org/gib/convexmonorepo-next:latest
container_name: convexmonorepo-next
hostname: convexmonorepo-next
domainname: ${NEXT_DOMAIN}
networks: ['${NETWORK:-nginx-bridge}']
#ports: ['${NEXT_PORT}:${NEXT_PORT}']
environment:
- NODE_ENV=${NODE_ENV}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
- NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
- PAYLOAD_SECRET=${PAYLOAD_SECRET}
- PAYLOAD_DB_URL=${PAYLOAD_DB_URL}
labels: ['com.centurylinklabs.watchtower.enable=true']
depends_on: ['convexmonorepo-backend']
tty: true
stdin_open: true
restart: unless-stopped
convexmonorepo-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-convex-backend}
hostname: ${BACKEND_CONTAINER_NAME:-convex-backend}
domainname: ${BACKEND_DOMAIN:-convex.gbrown.org}
networks: ['${NETWORK:-nginx-bridge}']
#user: '1000:1000'
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
volumes: [./data:/convex/data:z]
labels: ['com.centurylinklabs.watchtower.enable=true']
environment:
- INSTANCE_NAME
#- INSTANCE_SECRET
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}}
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${SITE_PROXY_PORT:-3211}}
- DISABLE_BEACON=${DISABLE_BEACON:-true}
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true}
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false}
#- POSTGRES_URL=${POSTGRES_URL}
stdin_open: true
tty: true
restart: unless-stopped
healthcheck:
test: curl -f http://localhost:3210/version
interval: 5s
start_period: 10s
stop_grace_period: 10s
stop_signal: SIGINT
convexmonorepo-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-convex-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.gbrown.org}}
networks: ['${NETWORK:-nginx-bridge}']
#user: 1000:1000
#ports: ['${DASHBOARD_PORT:-6791}:6791']
labels: ['com.centurylinklabs.watchtower.enable=true']
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${PORT:-3210}}
depends_on:
convexmonorepo-backend:
condition: service_healthy
stdin_open: true
tty: true
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: ConvexMonorepo
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/ConvexMonorepo
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=900
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+6
View File
@@ -0,0 +1,6 @@
NEXTAUTH_SECRET=CHANGE_ME
NEXT_PRIVATE_ENCRYPTION_KEY=CHANGE_ME
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=CHANGE_ME
NEXT_PRIVATE_DATABASE_URL=CHANGE_ME
NEXT_PRIVATE_DIRECT_DATABASE_URL=CHANGE_ME
NEXT_PRIVATE_SMTP_PASSWORD=CHANGE_ME
+72
View File
@@ -0,0 +1,72 @@
# Documenso — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12.
#
# ⚠️ NOT STARTED. No systemd unit is enabled for this. It has never run on either
# machine, so there is no data to migrate. Bring it up with:
# systemctl --user enable --now podman-documenso.service
#
# BEFORE FIRST START, two things must happen:
#
# 1. Create its role and database on the SHARED postgres (this stack no longer
# ships its own):
# podman exec -i postgresql psql -U npm <<'SQL'
# CREATE ROLE documenso LOGIN PASSWORD '<see this service .env on the machine>';
# CREATE DATABASE documenso OWNER documenso;
# SQL
# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql so a
# rebuild recreates them.
#
# 2. Provide a signing certificate at ./volumes/cert/cert.p12, or Documenso will
# not start. Generate one with:
# openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 3650 -nodes
# openssl pkcs12 -export -out cert.p12 -inkey key.pem -in cert.pem -passout pass:
#
# Also unset: the four NEXT_PRIVATE_SMTP_* values are still CHANGE_ME. Documenso will
# run without working email, but signature invitations will fail to send.
#
# Rootless adaptations from the home version:
# - Dropped its private postgres:16-alpine; uses the shared instance (AGENTS.md §2).
# - :Z on bind mounts (SELinux Enforcing).
# - /etc/localtime mounts removed in favour of TZ (AGENTS.md §8).
#
# NPM: proxy docs.gbrown.org -> http://documenso:3000
networks:
nginx-bridge:
external: true
services:
documenso:
image: docker.io/documenso/documenso:latest
container_name: documenso
hostname: documenso
domainname: docs.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PORT=3000
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXT_PRIVATE_ENCRYPTION_KEY=${NEXT_PRIVATE_ENCRYPTION_KEY}
- NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=${NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY}
- NEXT_PUBLIC_WEBAPP_URL=https://docs.gbrown.org
- NEXTAUTH_URL=https://docs.gbrown.org
# Shared postgres, not a private container
- NEXT_PRIVATE_DATABASE_URL=${NEXT_PRIVATE_DATABASE_URL}
- NEXT_PRIVATE_DIRECT_DATABASE_URL=${NEXT_PRIVATE_DIRECT_DATABASE_URL}
- NEXT_PRIVATE_SIGNING_TRANSPORT=local
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
- NEXT_PRIVATE_SIGNING_PASSPHRASE=
- NEXT_PUBLIC_UPLOAD_TRANSPORT=database
- NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
- NEXT_PRIVATE_SMTP_HOST=CHANGE_ME
- NEXT_PRIVATE_SMTP_PORT=587
- NEXT_PRIVATE_SMTP_USERNAME=CHANGE_ME
- NEXT_PRIVATE_SMTP_PASSWORD=${NEXT_PRIVATE_SMTP_PASSWORD}
- NEXT_PRIVATE_SMTP_FROM_NAME=Documenso
- [email protected]
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/cert/cert.p12:/opt/documenso/cert.p12:Z
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Documenso
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Documenso
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=900
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+25
View File
@@ -0,0 +1,25 @@
networks:
nginx-bridge:
external: true
services:
gitea:
image: docker.io/gitea/gitea:latest
container_name: gitea
hostname: gitea
domainname: git.gbrown.org
networks:
- nginx-bridge
ports:
- '2222:22'
environment:
- TZ=America/New_York
- USER_UID=1001
- USER_GID=1003
labels:
com.centurylinklabs.watchtower.enable: 'true'
volumes:
- ./volume:/data:Z
- /home/gib/Media/gitea-packages:/data/gitea/packages:Z
- ./ssh:/data/git/.ssh:Z
tty: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Gitea
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Gitea
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
@@ -0,0 +1,30 @@
# iSponsorBlockTV — VPS (ROOTLESS PODMAN) port of the home server's
# ~/Server/isponsorblocktv, migrated 2026-08-12.
#
# Why this works off-LAN: the configured device is pinned by `screen_id`, which uses
# YouTube's CLOUD lounge API rather than a direct LAN connection to the Apple TV. So it
# does not need to be on the same network as the TV. (If you ever re-pair a device via
# SSDP/mDNS discovery, that step DOES need LAN access and must be done from home.)
#
# Rootless adaptations:
# - `user: 1000:1000` REPLACED with userns_mode keep-id. Under a rootless userns the
# original would resolve to subuid 525287 and lose access to its own gib-owned data
# directory -- the exact failure that kept n8n broken for two months. See AGENTS.md §3.2.
# - :Z on the data mount (SELinux Enforcing).
# - network_mode: host retained to match home. It works fine rootless.
services:
isponsorblocktv:
image: ghcr.io/dmunozv04/isponsorblocktv:latest
container_name: iSponsorBlockTV
network_mode: host
userns_mode: "keep-id:uid=1000,gid=1000"
environment:
- TZ=America/New_York
volumes:
- './volumes/isponsorblocktv:/app/data:Z'
labels:
com.centurylinklabs.watchtower.enable: 'true'
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: iSponsorBlockTV
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/ISponsorBlockTV
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+142
View File
@@ -0,0 +1,142 @@
# Keys
# Required key for platform encryption/decryption ops
# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NEVER BE USED FOR PRODUCTION
ENCRYPTION_KEY=CHANGE_ME
# JWT
# Required secrets to sign JWT tokens
# THIS IS A SAMPLE AUTH_SECRET KEY AND SHOULD NEVER BE USED FOR PRODUCTION
AUTH_SECRET=CHANGE_ME
# Postgres creds
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_USER=infisical_user
POSTGRES_DB=infisical_db
# Required
DB_CONNECTION_URI=CHANGE_ME
# Redis
REDIS_URL=redis://infisical-redis:6379
# Website URL
# Required
SITE_URL=https://infisical.gbrown.org
# Mail/SMTP
SMTP_HOST=smtp.mail.me.com
SMTP_PORT=587
SMTP_FROM_ADDRESS=[email protected]
SMTP_FROM_NAME=Infisical Admin
SMTP_USERNAME=CHANGE_ME
SMTP_PASSWORD=CHANGE_ME
# CICD Integration
CLIENT_ID_GITHUB=
CLIENT_ID_GITHUB_APP=
CLIENT_SLUG_GITHUB_APP=
CLIENT_SECRET_GITHUB=
CLIENT_SECRET_GITHUB_APP=
CLIENT_ID_GITLAB=
CLIENT_SECRET_GITLAB=
CLIENT_PRIVATE_KEY_GITHUB_APP=
CLIENT_APP_ID_GITHUB_APP=
# Sentry (optional) for monitoring errors
SENTRY_DSN=
# Infisical Cloud-specific configs
# Ignore - Not applicable for self-hosted version
POSTHOG_HOST=
POSTHOG_PROJECT_API_KEY=
# SSO-specific variables
CLIENT_ID_GOOGLE_LOGIN=
CLIENT_SECRET_GOOGLE_LOGIN=
CLIENT_ID_GITHUB_LOGIN=
CLIENT_SECRET_GITHUB_LOGIN=
CLIENT_ID_GITLAB_LOGIN=
CLIENT_SECRET_GITLAB_LOGIN=
CAPTCHA_SECRET=
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
OTEL_TELEMETRY_COLLECTION_ENABLED=false
OTEL_EXPORT_TYPE=prometheus
OTEL_EXPORT_OTLP_ENDPOINT=
OTEL_OTLP_PUSH_INTERVAL=
OTEL_COLLECTOR_BASIC_AUTH_USERNAME=
OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=
PLAIN_API_KEY=
PLAIN_WISH_LABEL_IDS=
SSL_CLIENT_CERTIFICATE_HEADER_KEY=
ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT=true
# App Connections
# aws assume-role connection
INF_APP_CONNECTION_AWS_ACCESS_KEY_ID=
INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY=
# github oauth connection
INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID=
INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET=
#github app connection
INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID=
INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET=
INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY=
INF_APP_CONNECTION_GITHUB_APP_SLUG=
INF_APP_CONNECTION_GITHUB_APP_ID=
#gitlab app connection
INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID=
INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET=
#github radar app connection
INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID=
INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET=
INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY=
INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG=
INF_APP_CONNECTION_GITHUB_RADAR_APP_ID=
INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET=
#gcp app connection
INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL=
# azure app connections
INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID=
INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET=
INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID=
INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET=
INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID=
INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET=
INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID=
INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET=
# heroku app connection
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID=
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET=
# datadog
SHOULD_USE_DATADOG_TRACER=
DATADOG_PROFILING_ENABLED=
DATADOG_ENV=
DATADOG_SERVICE=
DATADOG_HOSTNAME=
# kubernetes
KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN=false
# ClickHouse (optional) for audit log storage
# CLICKHOUSE_URL=http://infisical:infisical@clickhouse:8123/infisical
+62
View File
@@ -0,0 +1,62 @@
# Infisical — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/infisical,
# migrated 2026-08-12.
#
# CONSOLIDATED ONTO THE SHARED POSTGRES. Home runs a dedicated postgres:14 for infisical;
# here it uses the shared `postgresql` container (PG 17) alongside authentik, n8n and NPM.
# The PG14 dump restored into PG17 cleanly (no extensions, 749 tables, row counts verified
# identical). One postgres to back up, patch and monitor instead of several.
#
# role/database: infisical_user / infisical_db (created in the shared instance)
# DB_CONNECTION_URI in .env points at host `postgresql`, not the old `db`
# REDIS_URL points at `infisical-redis` -- container_name is what netavark resolves
# on nginx-bridge, so the home file's bare `redis` hostname does not work here
#
# redis stays local to this stack: it holds only cache/queue state, starts empty by
# design, and is not worth centralising.
#
# Rootless adaptations:
# - :Z on the redis data mount (SELinux Enforcing).
# - No published ports; NPM proxies to http://infisical-backend:8080 over nginx-bridge.
# - Images start as root and drop privileges internally, so no userns_mode needed.
#
# .env is copied verbatim from home (mode 600) and holds ENCRYPTION_KEY and AUTH_SECRET.
# Those MUST match the database they encrypted -- never regenerate them on migrated data.
networks:
nginx-bridge:
external: true
services:
redis:
image: docker.io/library/redis:latest
container_name: infisical-redis
hostname: infisical-redis
networks: ["nginx-bridge"]
env_file: .env
environment:
- ALLOW_EMPTY_PASSWORD=yes
- TZ=America/New_York
volumes:
- ./data/redis:/data:Z
labels:
com.centurylinklabs.watchtower.enable: "true"
restart: unless-stopped
tty: true
backend:
image: docker.io/infisical/infisical:latest
container_name: infisical-backend
hostname: infisical-backend
domainname: infisical.gbrown.org
networks: ["nginx-bridge"]
env_file: .env
environment:
- NODE_ENV=production
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "true"
depends_on:
- redis
restart: unless-stopped
tty: true
stdin_open: true
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Infisical
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Infisical
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+64
View File
@@ -0,0 +1,64 @@
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
# Keep this file up-to-date when you add new variables to \`.env\`.
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
## Next.js ##
NODE_ENV=production
SENTRY_AUTH_TOKEN=CHANGE_ME
PAYLOAD_SECRET=CHANGE_ME
PAYLOAD_DB_URL=CHANGE_ME
NEXT_PUBLIC_SITE_URL=https://lashaddict.gbrown.org
NEXT_PUBLIC_CONVEX_URL=https://api.lashaddict.gbrown.org # convex-backend:3210
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.gbrown.org
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/7
NEXT_PUBLIC_SENTRY_URL=https://sentry.gbrown.org
NEXT_PUBLIC_SENTRY_ORG=sentry
NEXT_PUBLIC_SENTRY_PROJECT_NAME=lashaddict-next
## Convex ##
CONVEX_SELF_HOSTED_URL=https://api.lashaddict.gbrown.org # convex-backend:3210
CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME
# Convex Auth
CONVEX_SITE_URL=https://lashaddict.gbrown.org
USESEND_API_KEY=CHANGE_ME
USESEND_URL=https://usesend.gbrown.org
USESEND_FROM_EMAIL='Admin <[email protected]>'
AUTH_AUTHENTIK_ID=CHANGE_ME
AUTH_AUTHENTIK_SECRET=CHANGE_ME
AUTH_AUTHENTIK_ISSUER=https://auth.gbrown.org/application/o/lashaddict/
## Docker Compose Variables for Next App ##
NETWORK=nginx-bridge
NEXT_CONTAINER_NAME=lashaddict-next
NEXT_DOMAIN=lashaddict.gbrown.org
#NEXT_PORT=
## Docker Compose Variables for Self hosted Convex ##
BACKEND_TAG=latest
DASHBOARD_TAG=latest
BACKEND_CONTAINER_NAME=lashaddict-backend
DASHBOARD_CONTAINER_NAME=lashaddict-dashboard
BACKEND_DOMAIN=convex.lashaddict.gbrown.org
DASHBOARD_DOMAIN=dashboard.lashaddict.gbrown.org
INSTANCE_NAME=lashaddict_convex
INSTANCE_SECRET=CHANGE_ME
CONVEX_CLOUD_ORIGIN=https://api.lashaddict.gbrown.org
CONVEX_SITE_ORIGIN=https://convex.lashaddict.gbrown.org
NEXT_PUBLIC_DEPLOYMENT_URL=https://api.lashaddict.gbrown.org
DISABLE_BEACON=true
REDACT_LOGS_TO_CLIENT=true
DO_NOT_REQUIRE_SSL=true
POSTGRES_URL=CHANGE_ME
#BACKEND_PORT=
#DASHBOARD_PORT
#SITE_PROXY_PORT=
#ACTIONS_USER_TIMEOUT_SECS=
#RUST_LOG=
#RUST_BACKTRACE=
## Docker Compose Variables for Postgres ##
POSTGRES_CONTAINER_NAME=lashaddict-postgres
POSTGRES_USER=gib
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=lashaddict-payload
+76
View File
@@ -0,0 +1,76 @@
networks:
nginx-bridge:
external: true
services:
lashaddict-next:
image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest
container_name: ${NEXT_CONTAINER_NAME}
hostname: ${NEXT_CONTAINER_NAME}
domainname: ${NEXT_DOMAIN}
networks:
- ${NETWORK:-nginx-bridge}
environment:
- NODE_ENV=${NODE_ENV}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-lashaddict-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
- NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
- PAYLOAD_SECRET=${PAYLOAD_SECRET}
- PAYLOAD_DB_URL=${PAYLOAD_DB_URL}
depends_on:
- lashaddict-backend
tty: true
stdin_open: true
restart: unless-stopped
lashaddict-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-lashaddict-backend}
hostname: ${BACKEND_CONTAINER_NAME:-lashaddict-backend}
domainname: ${BACKEND_DOMAIN:-lashaddict.gbrown.org}
networks:
- ${NETWORK:-nginx-bridge}
volumes:
- ./volumes/convex:/convex/data:z
labels:
- com.centurylinklabs.watchtower.enable=true
environment:
- INSTANCE_NAME=${INSTANCE_NAME}
- INSTANCE_SECRET=${INSTANCE_SECRET}
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}}
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${SITE_PROXY_PORT:-3211}}
- DISABLE_BEACON=${DISABLE_BEACON:-true}
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true}
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false}
- POSTGRES_URL=${POSTGRES_URL}
stdin_open: true
tty: true
restart: unless-stopped
healthcheck:
test: curl -f http://localhost:3210/version
interval: 5s
start_period: 10s
stop_grace_period: 10s
stop_signal: SIGINT
lashaddict-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-lashaddict-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-lashaddict.gbrown.org}}
networks:
- ${NETWORK:-nginx-bridge}
labels:
- com.centurylinklabs.watchtower.enable=true
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-lashaddict-backend}:${PORT:-3210}}
depends_on:
lashaddict-backend:
condition: service_healthy
stdin_open: true
tty: true
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: LashAddict
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/LashAddict
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=900
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+16
View File
@@ -0,0 +1,16 @@
GENERIC_TIMEZONE=America/New_York
TZ=America/New_York
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
N8N_RUNNERS_ENABLED=true
N8N_RUNNERS_MODE=external
N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
N8N_RUNNERS_AUTH_TOKEN=CHANGE_ME
N8N_NATIVE_PYTHON_RUNNER=true
DB_TYPE=postgresdb
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_HOST=postgresql
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_USER=npm
DB_POSTGRESDB_PASSWORD=CHANGE_ME
N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
N8N_RUNNERS_AUTH_TOKEN=CHANGE_ME
+50
View File
@@ -0,0 +1,50 @@
networks:
nginx-bridge:
external: true
services:
n8n:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n
hostname: n8n
domainname: n8n.gbrown.org
networks: ['nginx-bridge']
#ports: ['5678:5678']
env_file: [.env]
environment:
- GENERIC_TIMEZONE
- TZ
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS
- N8N_RUNNERS_ENABLED
- N8N_RUNNERS_MODE
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS
- N8N_RUNNERS_AUTH_TOKEN
- N8N_NATIVE_PYTHON_RUNNER
- DB_TYPE
- DB_POSTGRESDB_DATABASE
- DB_POSTGRESDB_HOST
- DB_POSTGRESDB_PORT
- DB_POSTGRESDB_USER
- DB_POSTGRESDB_PASSWORD
# Rootless: replaces `user: 1000:1000`, which under a rootless userns would have
# resolved to subuid 525287 and lost access to the gib-owned ./data dir.
userns_mode: "keep-id:uid=1000,gid=1000"
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- './data:/home/node/.n8n:Z'
tty: true
stdin_open: true
restart: unless-stopped
task-runners:
image: n8nio/runners:1.111.0
container_name: n8n-runners
# Was missing: without this the runner sits on the compose default network and
# cannot resolve N8N_RUNNERS_TASK_BROKER_URI (http://n8n:5679).
networks: ['nginx-bridge']
env_file: [.env]
environment:
- N8N_RUNNERS_TASK_BROKER_URI
- N8N_RUNNERS_AUTH_TOKEN
depends_on:
- n8n
+21
View File
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: N8n
After=network-online.target podman-postgresql.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/N8n
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
@@ -0,0 +1,5 @@
DB_POSTGRES_HOST=postgresql
DB_POSTGRES_PORT=5432
DB_POSTGRES_USER=npm
DB_POSTGRES_PASSWORD=CHANGE_ME
DB_POSTGRES_NAME=npm
@@ -0,0 +1,52 @@
services:
nginx-proxy-manager:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
hostname: nginx-proxy-manager
domainname: nginx.gibbyb.com
networks: ['nginx-bridge']
restart: unless-stopped
ports:
- '80:80'
- '443:443'
# ⚠️ ONLY 81 moves. 80 and 443 above MUST stay on 0.0.0.0 — they are the
# public front door and firewalld already restricts them to Cloudflare ranges.
# 81 is the admin UI and is reached over WireGuard (http://192.168.2.2:81).
- '192.168.2.2:81:81' # Admin Web Port — WireGuard only
#- '21:21' # FTP
#- '22:22' # SSH
#- '25565:25565' # Minecraft
environment:
- TZ=America/New_York
- DB_POSTGRES_HOST
- DB_POSTGRES_PORT
- DB_POSTGRES_USER
- DB_POSTGRES_PASSWORD
- DB_POSTGRES_NAME
labels:
com.centurylinklabs.watchtower.enable: 'true'
volumes:
- ./volumes/data:/data:Z
- ./volumes/letsencrypt:/etc/letsencrypt:Z
#depends_on: [postgresql]
#postgresql:
#image: postgres:17
#container_name: nginx-proxy-manager-db
#hostname: nginx-proxy-manager-db
#networks: ['nginx-bridge']
#environment:
#POSTGRES_USER: 'npm'
#POSTGRES_PASSWORD: '<see this service .env on the machine>'
#POSTGRES_DB: 'npm'
#labels:
#com.centurylinklabs.watchtower.enable: 'true'
#volumes:
#- ./volumes/postgres:/var/lib/postgresql/data
#restart: unless-stopped
networks:
# Created out-of-band so all nine stacks agree and there is no create race at boot:
# podman network create --subnet 172.18.0.0/24 nginx-bridge
nginx-bridge:
external: true
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Nginx Proxy Manager
After=network-online.target podman-postgresql.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Nginx_Proxy_Manager
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+2
View File
@@ -0,0 +1,2 @@
PENPOT_SECRET_KEY=CHANGE_ME
PENPOT_DATABASE_PASSWORD=CHANGE_ME
+120
View File
@@ -0,0 +1,120 @@
# Penpot — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12.
#
# ⚠️ NOT STARTED. No systemd unit is enabled. It has never run on either machine, so
# there is no data to migrate. Bring it up with:
# systemctl --user enable --now podman-penpot.service
#
# BEFORE FIRST START, create its role and database on the SHARED postgres (this stack
# no longer ships its own):
# podman exec -i postgresql psql -U npm <<'SQL'
# CREATE ROLE penpot LOGIN PASSWORD '<see this service .env on the machine>';
# CREATE DATABASE penpot OWNER penpot;
# SQL
# Then add the same lines to PostgreSQL/initdb/00-roles-and-databases.sql.
#
# NOTE: the home version used postgres:15-alpine with --data-checksums. The shared
# instance is postgres 17 without checksums. Penpot does not care, and since there is
# no existing data this is not a migration — but if you ever DO need checksums, they
# can only be set at initdb time for the whole cluster.
#
# Redis stays local to this stack: Penpot uses it for transient session and
# notification state only, so it is not worth centralising.
#
# Rootless adaptations from the home version:
# - Dropped its private postgres:15-alpine; uses the shared instance (AGENTS.md §2).
# - PENPOT_DATABASE_URI repointed from penpot-db to the shared postgresql.
# - :Z on the assets bind mount. It is shared between frontend and backend, so it
# uses LOWERCASE :z -- uppercase would give each container a private MCS category
# and the second to start would lock the first out (AGENTS.md §6).
# - /etc/localtime mounts removed in favour of TZ (AGENTS.md §8).
#
# Registration is disabled by default (PENPOT_FLAGS disable-registration), so create
# the first account from the backend container before you can log in.
#
# NPM: proxy penpot.gbrown.org -> http://penpot-frontend:8080
networks:
nginx-bridge:
external: true
services:
penpot-frontend:
image: docker.io/penpotapp/frontend:latest
container_name: penpot-frontend
hostname: penpot-frontend
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PENPOT_FLAGS=disable-registration enable-login-with-password disable-smtp
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/assets:/opt/data/assets:z
depends_on:
- penpot-backend
- penpot-exporter
tty: true
stdin_open: true
restart: unless-stopped
penpot-backend:
image: docker.io/penpotapp/backend:latest
container_name: penpot-backend
hostname: penpot-backend
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PENPOT_FLAGS=disable-registration enable-login-with-password disable-smtp enable-prepl-server
- PENPOT_PUBLIC_URI=https://penpot.gbrown.org
- PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY}
# Shared postgres, not a private container
- PENPOT_DATABASE_URI=postgresql://postgresql/penpot
- PENPOT_DATABASE_USERNAME=penpot
- PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD}
- PENPOT_REDIS_URI=redis://penpot-redis/0
- PENPOT_ASSETS_STORAGE_BACKEND=assets-fs
- PENPOT_STORAGE_ASSETS_FS_DIRECTORY=/opt/data/assets
- PENPOT_TELEMETRY_ENABLED=false
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/assets:/opt/data/assets:z
depends_on:
- penpot-redis
tty: true
stdin_open: true
restart: unless-stopped
penpot-exporter:
image: docker.io/penpotapp/exporter:latest
container_name: penpot-exporter
hostname: penpot-exporter
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PENPOT_PUBLIC_URI=http://penpot-frontend:8080
- PENPOT_REDIS_URI=redis://penpot-redis/0
labels:
com.centurylinklabs.watchtower.enable: "true"
depends_on:
- penpot-redis
tty: true
stdin_open: true
restart: unless-stopped
penpot-redis:
image: docker.io/library/redis:7-alpine
container_name: penpot-redis
hostname: penpot-redis
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Penpot
After=network-online.target podman.socket podman-postgresql.service
Wants=network-online.target podman-postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Penpot
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=900
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+31
View File
@@ -0,0 +1,31 @@
services:
portainer:
image: portainer/portainer-ee:latest
container_name: portainer
hostname: portainer
domainname: port.gibbyb.com
networks:
- nginx-bridge
environment:
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "true"
ports:
# WireGuard address only — this admin UI has no business on the public
# interface. It has its own login and the Hetzner firewall does not admit this
# port, so this is defence in depth: it removes the dependency on a firewall rule
# set that lives in a web console. See AGENTS.md §13.2.
- 192.168.2.2:9443:9443
# Required under SELinux Enforcing to reach the rootless podman socket, which
# systemd recreates at each boot with a non-container label. The socket mount
# deliberately has no :Z -- relabeling the live socket would break podman itself.
security_opt: ['label:disable']
volumes:
- ./volumes/data:/data:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock
tty: true
restart: unless-stopped
networks:
nginx-bridge:
external: true
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Portainer
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Portainer
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
@@ -0,0 +1,4 @@
POSTGRES_USER=npm
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_NAME=npm
POSTGRES_DB=npm
+80
View File
@@ -0,0 +1,80 @@
networks:
nginx-bridge:
external: true
services:
postgresql:
# pgvector image = stock postgres:17 plus the `vector` extension compiled in. Same
# PostgreSQL 17.10 and same data directory format, so this is a drop-in swap with no
# dump/restore needed.
#
# ⚠️ THE TAG MUST STAY -trixie. This is not cosmetic.
#
# The plain `pgvector/pgvector:pg17` tag is built on Debian 12 (bookworm, glibc 2.36).
# The stock `postgres:17` this cluster was created under is Debian 13 (trixie,
# glibc 2.41). glibc supplies the collation used to order every text index, and it is
# NOT guaranteed stable across versions. Starting on bookworm made all 14 databases
# report:
# WARNING: database "npm" has a collation version mismatch
# DETAIL: created using collation version 2.41, but the OS provides version 2.36
# An index built under one collation and read under another can silently return wrong
# results — missed rows in range scans, duplicate values slipping past unique
# constraints. It does not error; it just quietly answers incorrectly.
#
# Using the -trixie build keeps glibc at 2.41, matching how the data was written, so
# no REINDEX is required. If you ever must move to a different base, the correct
# procedure is: REINDEX DATABASE <each>, then ALTER DATABASE <each> REFRESH COLLATION
# VERSION — not simply silencing the warning.
#
# The extension is AVAILABLE but not enabled anywhere by default. To use it in a
# database, enable it per-database (it is not cluster-wide):
# podman exec postgresql psql -U npm -d <dbname> -c 'CREATE EXTENSION vector;'
#
# ⚠️ Do NOT let watchtower update this to a different pg major. It is already in
# WATCHTOWER_DISABLE_CONTAINERS, which is what keeps that from happening.
image: pgvector/pgvector:pg17-trixie
container_name: postgresql
# Rootless: maps host uid 1000 (gib) -> container uid 999 (postgres), so ./pg_data
# stays gib-owned on the host. Postgres' entrypoint supports running as non-root
# provided the data dir ownership matches, which this guarantees.
userns_mode: "keep-id:uid=999,gid=999"
hostname: postgresql
domainname: pg.gbrown.org
networks:
- nginx-bridge
# ⚠️ WIREGUARD ADDRESS ONLY. This was previously '5432:5432', which bound the
# database to every interface including the public one — the only thing preventing
# the internet from reaching a Postgres auth prompt was firewalld plus the Hetzner
# cloud firewall. Two independent firewall rules were the sole barrier in front of
# all 14 databases.
#
# Nothing needs the public binding: every consumer (authentik, infisical, adminer,
# the payload/convex app stacks, gitea) resolves `postgresql` over the nginx-bridge
# container network and never touches the published port at all. This mapping now
# exists purely so you can point a desktop client at it across the tunnel.
#
# Same pattern as watchtower's API and authentik's 9000 — see AGENTS.md §5.
ports: ['192.168.2.2:5432:5432']
env_file: .env
environment:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_DB
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./pg_data:/var/lib/postgresql/data:Z
# Runs ONLY when pg_data is empty (fresh install). Recreates every role and
# database this VPS needs, so the environment can be rebuilt from scratch without
# hand-creating them. Does nothing to an existing database — safe to leave mounted.
# The committed file is 00-roles-and-databases.sql.TEMPLATE with placeholder
# passwords; copy it to .sql and fill in real values from each service's .env.
- ./initdb:/docker-entrypoint-initdb.d:Z
tty: true
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
start_period: 20s
interval: 30s
retries: 5
timeout: 5s
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: PostgreSQL
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/PostgreSQL
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+1
View File
@@ -0,0 +1 @@
RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME
+89
View File
@@ -0,0 +1,89 @@
# RustDesk Server — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/rustdesk,
# migrated 2026-08-12.
#
# ⚠️ THIS SERVICE CANNOT GO BEHIND CLOUDFLARE'S PROXY.
# RustDesk's ID/relay protocol is raw TCP/UDP on 21115-21119. Cloudflare's proxy only
# carries HTTP/HTTPS; raw TCP needs Spectrum, which is not on this plan. So
# rustdesk.gbrown.org MUST be a grey-cloud (DNS-only) record pointing at
# 178.156.197.55, and these ports must be open in firewalld's public zone.
#
# Consequence, accepted deliberately: this publishes the VPS's real IP. That is
# tolerable because the firewall still restricts 80/443 to Cloudflare ranges, so
# knowing the IP does not grant access to any of the web services.
#
# The server keypair in ./volumes/server/id_ed25519 was carried over from the home
# server. Its public half matches RUSTDESK_API_RUSTDESK_KEY below
# (WLgvHhau6aa5nDPQutTHeQBpIrOEb8aPXByVBWQwkKc=), which is what lets existing clients
# reconnect without being re-paired. NEVER regenerate it.
#
# Rootless adaptations:
# - :Z on both bind mounts (SELinux Enforcing); each is exclusive to this container.
# - All published ports are >1024 so no privileged-port handling is needed.
# - The image starts as root under s6 and drops privileges internally, so no
# userns_mode is required.
#
# PORT REFERENCE (all must be open in the firewall):
# 21114/tcp web UI + API 21117/tcp hbbr relay
# 21115/tcp hbbs NAT type test 21118/tcp websocket (web client)
# 21116/tcp hbbs ID registration 21119/tcp websocket relay
# 21116/udp hbbs heartbeat <-- UDP, easy to forget
networks:
nginx-bridge:
external: true
services:
rustdesk-server:
image: docker.io/lejianwen/rustdesk-server-s6:latest
container_name: rustdesk-server
hostname: rustdesk-server
domainname: rustdesk.gbrown.org
networks: ['nginx-bridge']
ports:
- 21114:21114
- 21115:21115
- 21116:21116
- 21116:21116/udp
- 21117:21117
- 21118:21118
- 21119:21119
environment:
- MUST_LOGIN=Y
- TZ=America/New_York
# SPLIT HOSTNAMES — this is deliberate, do not "simplify" it back to one name.
# relay.gbrown.org DNS-only (grey cloud) -> 178.156.197.55
# Carries the raw TCP/UDP protocol on 21115-21119.
# Cloudflare's proxy only handles HTTP/HTTPS, so this
# hostname MUST bypass it. That publishes the VPS IP,
# which is acceptable: the firewall still restricts
# 80/443 to Cloudflare ranges, so knowing the IP grants
# no access to any web service.
# rustdesk.gbrown.org Proxied -> NPM -> rustdesk-server:21114
# The web console and API, over TLS via Cloudflare.
# Keeping the API on the proxied name is what avoids sending login
# credentials over plain HTTP.
# RELAY is read by the s6 run script as `hbbs -r $RELAY`. It is what hbbs
# hands back to clients when P2P hole-punching fails, so it MUST be set here:
# the RUSTDESK_API_* vars below only configure the web console/API, not hbbs.
# Left unset, the image defaults to `relay.example.com`, which resolves to
# nothing -- so every connection needing a relay (i.e. every off-LAN,
# off-WireGuard client behind CGNAT) silently fails.
- RELAY=relay.gbrown.org
- RUSTDESK_API_RUSTDESK_ID_SERVER=relay.gbrown.org
- RUSTDESK_API_RUSTDESK_RELAY_SERVER=relay.gbrown.org
- RUSTDESK_API_RUSTDESK_API_SERVER=https://rustdesk.gbrown.org
- RUSTDESK_API_RUSTDESK_KEY=WLgvHhau6aa5nDPQutTHeQBpIrOEb8aPXByVBWQwkKc=
- RUSTDESK_API_RUSTDESK_JWT_KEY=${RUSTDESK_API_RUSTDESK_JWT_KEY}
- RUSTDESK_API_LANG=en
- RUSTDESK_API_APP_DISABLE_PWD_LOGIN=true
- RUSTDESK_API_ADMIN_TITLE=Gib's Rustdesk
- RUSTDESK_API_ADMIN_HELLO=<h1>Welcome to Gib's Rustdesk<h1>
- ENCRYPTED_ONLY=1
- RUSTDESK_API_RUSTDESK_WEBCLIENT_MAGIC_QUERYONLINE=1
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volumes/server:/data:Z
- ./volumes/api:/app/data:Z
restart: unless-stopped
tty: true
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Rustdesk
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Rustdesk
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+66
View File
@@ -0,0 +1,66 @@
AUTH_AUTHENTIK_ID=CHANGE_ME
AUTH_AUTHENTIK_ISSUER="https://auth.gbrown.org/application/o/spoon/"
AUTH_AUTHENTIK_SECRET=CHANGE_ME
AUTH_GITHUB_ID="Iv23liygrKjd17rba96x"
AUTH_GITHUB_SECRET=CHANGE_ME
BACKEND_CONTAINER_NAME="spoon-backend"
BACKEND_DOMAIN="convex.spoon.gbrown.org"
BACKEND_PORT="3210"
BACKEND_TAG="latest"
CONVEX_CLOUD_ORIGIN="https://api.spoon.gbrown.org"
CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME
CONVEX_SELF_HOSTED_URL="https://api.spoon.gbrown.org"
CONVEX_SITE_ORIGIN="https://convex.spoon.gbrown.org"
CONVEX_SITE_URL="https://spoon.gbrown.org"
DASHBOARD_CONTAINER_NAME="spoon-dashboard"
DASHBOARD_DOMAIN="dashboard.spoon.gbrown.org"
DASHBOARD_PORT="6791"
DASHBOARD_TAG="latest"
DISABLE_BEACON="true"
DO_NOT_REQUIRE_SSL="true"
GITHUB_APP_CLIENT_ID="Iv23liygrKjd17rba96x"
GITHUB_APP_CLIENT_SECRET=CHANGE_ME
GITHUB_APP_ID="4111484"
GITHUB_APP_INSTALLATION_ID="141786602"
GITHUB_APP_OWNER="gibbyb"
GITHUB_APP_PRIVATE_KEY=CHANGE_ME
GITHUB_APP_SLUG="spoon-gbrown"
GITHUB_APP_WEBHOOK_SECRET=CHANGE_ME
INSTANCE_NAME="convex"
NETWORK="nginx-bridge"
NEXT_CONTAINER_NAME="spoon-next"
NEXT_DOMAIN="spoon.gbrown.org"
NEXT_PORT="3000"
NEXT_PUBLIC_CONVEX_URL="https://api.spoon.gbrown.org"
NEXT_PUBLIC_DEPLOYMENT_URL="https://api.spoon.gbrown.org"
NEXT_PUBLIC_PLAUSIBLE_URL="https://plausible.gbrown.org"
NEXT_PUBLIC_SENTRY_DSN="https://[email protected]/8"
NEXT_PUBLIC_SENTRY_ORG="sentry"
NEXT_PUBLIC_SENTRY_PROJECT_NAME="spoon-nextjs"
NEXT_PUBLIC_SENTRY_URL="https://sentry.gbrown.org"
NEXT_PUBLIC_SITE_URL="https://spoon.gbrown.org"
NODE_ENV="production"
POSTGRES_CONTAINER_NAME="spoon-postgres"
POSTGRES_DB="spoon_convex"
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_PORT="5432"
POSTGRES_URL=CHANGE_ME
POSTGRES_USER="spoon"
REDACT_LOGS_TO_CLIENT="true"
SENTRY_AUTH_TOKEN=CHANGE_ME
SITE_PROXY_PORT="3211"
SPOON_ENCRYPTION_KEY=CHANGE_ME
SPOON_WORKER_TOKEN=CHANGE_ME
USESEND_API_KEY=CHANGE_ME
USESEND_FROM_EMAIL="Spoon Admin <[email protected]>"
USESEND_URL="https://usesend.gbrown.org"
SPOON_AGENT_JOB_IMAGE=git.gbrown.org/gib/spoon-agent-job:latest
SPOON_AGENT_JOB_TIMEOUT_MS="1800000"
SPOON_AGENT_MAX_CONCURRENT_JOBS="1"
SPOON_AGENT_NETWORK=nginx-bridge
SPOON_AGENT_RUNTIME=docker
SPOON_AGENT_WORKDIR=/var/lib/spoon-agent/work
SPOON_AGENT_WORKER_ID=production-worker
SPOON_AGENT_WORKER_HTTP_PORT="3921"
SPOON_AGENT_WORKER_INTERNAL_TOKEN=CHANGE_ME
SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921
+114
View File
@@ -0,0 +1,114 @@
networks:
nginx-bridge:
external: true
services:
spoon-next:
image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest
container_name: ${NEXT_CONTAINER_NAME}
hostname: ${NEXT_CONTAINER_NAME}
domainname: ${NEXT_DOMAIN}
networks:
- ${NETWORK:-nginx-bridge}
pull_policy: missing
environment:
- NODE_ENV=${NODE_ENV}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
- NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
- SPOON_AGENT_WORKER_URL=${SPOON_AGENT_WORKER_URL:-http://spoon-agent-worker:3921}
- SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN}
- SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN}
depends_on:
- spoon-backend
labels:
- com.centurylinklabs.watchtower.enable=true
tty: true
stdin_open: true
restart: unless-stopped
spoon-agent-worker:
image: git.gbrown.org/gib/spoon-agent-worker:latest
container_name: spoon-agent-worker
hostname: spoon-agent-worker
domainname: worker.${NEXT_DOMAIN:-spoon.gbrown.org}
networks:
- ${NETWORK:-nginx-bridge}
pull_policy: missing
environment:
- GITHUB_APP_ID=${GITHUB_APP_ID}
- GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
- NEXT_PUBLIC_CONVEX_URL=https://api.spoon.gbrown.org
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker}
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-git.gbrown.org/gib/spoon-agent-job:latest}
- SPOON_AGENT_RUNTIME=docker
- SPOON_AGENT_NETWORK=${NETWORK:-nginx-bridge}
- SPOON_AGENT_WORKDIR=/var/lib/spoon-agent/work
- SPOON_AGENT_HOST_WORKDIR=/var/lib/spoon-agent/work
- SPOON_AGENT_WORKER_HTTP_PORT=${SPOON_AGENT_WORKER_HTTP_PORT:-3921}
- SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN}
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
- SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN}
volumes:
- /run/user/1000/podman/podman.sock:/var/run/docker.sock
- ./volumes/agent-work:/var/lib/spoon-agent/work:z
labels:
- com.centurylinklabs.watchtower.enable=true
tty: true
stdin_open: true
restart: unless-stopped
security_opt:
- label:disable
spoon-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-spoon-backend}
hostname: ${BACKEND_CONTAINER_NAME:-spoon-backend}
domainname: ${BACKEND_DOMAIN:-convex.spoon.gbrown.org}
networks:
- ${NETWORK:-nginx-bridge}
volumes:
- ./volumes/convex:/convex/data:z
pull_policy: missing
environment:
- INSTANCE_NAME=${INSTANCE_NAME}
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${SITE_PROXY_PORT:-3211}}
- DISABLE_BEACON=${DISABLE_BEACON:-true}
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true}
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false}
- POSTGRES_URL=${POSTGRES_URL}
labels:
- com.centurylinklabs.watchtower.enable=true
stdin_open: true
tty: true
restart: unless-stopped
healthcheck:
test: curl -f http://localhost:3210/version
interval: 5s
start_period: 10s
stop_grace_period: 10s
stop_signal: SIGINT
spoon-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-spoon.gbrown.org}}
networks:
- ${NETWORK:-nginx-bridge}
pull_policy: missing
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${PORT:-3210}}
depends_on:
spoon-backend:
condition: service_healthy
labels:
- com.centurylinklabs.watchtower.enable=true
stdin_open: true
tty: true
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Spoon
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Spoon
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=900
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+58
View File
@@ -0,0 +1,58 @@
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo.
# Keep this file up-to-date when you add new variables to \`.env\`.
# This file will be committed to version control, so make sure not to have any secrets in it.
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
## Next App ##
NODE_ENV=production
SENTRY_AUTH_TOKEN=CHANGE_ME
PAYLOAD_SECRET=CHANGE_ME
PAYLOAD_DB_URL=CHANGE_ME
NEXT_PUBLIC_SITE_URL=https://stpeteit.com
NEXT_PUBLIC_CONVEX_URL=https://api.stpeteit.com # convex-backend:3210
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.gbrown.org
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/5
NEXT_PUBLIC_SENTRY_URL=https://sentry.gbrown.org
NEXT_PUBLIC_SENTRY_ORG=sentry
NEXT_PUBLIC_SENTRY_PROJECT_NAME=stpeteit-next
## Convex ##
CONVEX_SELF_HOSTED_URL=https://api.stpeteit.com # convex-backend:3210
CONVEX_SELF_HOSTED_ADMIN_KEY=CHANGE_ME
# Convex Auth
CONVEX_SITE_URL=https://stpeteit.com # convex-backend:3211
USESEND_API_KEY=CHANGE_ME
USESEND_URL=https://usesend.gbrown.org
USESEND_FROM_EMAIL='St Pete IT Admin <[email protected]>'
AUTH_AUTHENTIK_ID=CHANGE_ME
AUTH_AUTHENTIK_SECRET=CHANGE_ME
AUTH_AUTHENTIK_ISSUER=https://auth.gbrown.org/application/o/stpeteit/
## Docker Compose Variables for Next App ##
NETWORK=nginx-bridge
NEXT_CONTAINER_NAME=stpeteit-next
NEXT_DOMAIN=stpeteit.com
#NEXT_PORT=3000
## Docker Compose Variables for Self hosted Convex ##
BACKEND_TAG=latest
DASHBOARD_TAG=latest
BACKEND_CONTAINER_NAME=stpeteit-backend
DASHBOARD_CONTAINER_NAME=stpeteit-dashboard
BACKEND_DOMAIN=convex.stpeteit.com
DASHBOARD_DOMAIN=dashboard.stpeteit.com
INSTANCE_NAME=stpeteit-convex
#INSTANCE_SECRET=
CONVEX_CLOUD_ORIGIN=https://api.stpeteit.com
CONVEX_SITE_ORIGIN=https://convex.stpeteit.com
NEXT_PUBLIC_DEPLOYMENT_URL=https://api.stpeteit.com
DISABLE_BEACON=true
REDACT_LOGS_TO_CLIENT=true
DO_NOT_REQUIRE_SSL=true
POSTGRES_URL=CHANGE_ME
#BACKEND_PORT=
#DASHBOARD_PORT
#SITE_PROXY_PORT=
#ACTIONS_USER_TIMEOUT_SECS=
#RUST_LOG=
#RUST_BACKTRACE=
+101
View File
@@ -0,0 +1,101 @@
networks:
nginx-bridge: # Change to network you plan to use
external: true
services:
stpeteit-next:
image: git.gbrown.org/gib/stpeteit-next:latest
container_name: stpeteit-next
hostname: stpeteit-next
domainname: ${NEXT_DOMAIN}
networks: ['${NETWORK:-nginx-bridge}']
#ports: ['${NEXT_PORT}:${NEXT_PORT}']
environment:
- NODE_ENV=${NODE_ENV:-development}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- PAYLOAD_SECRET=${PAYLOAD_SECRET}
- PAYLOAD_DB_URL=${PAYLOAD_DB_URL}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}}
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.stpeteit.com}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
- NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
labels: ['com.centurylinklabs.watchtower.enable=true']
depends_on: ['stpeteit-backend']
tty: true
stdin_open: true
restart: unless-stopped
stpeteit-backend:
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
container_name: ${BACKEND_CONTAINER_NAME:-stpeteit-backend}
hostname: ${BACKEND_CONTAINER_NAME:-stpeteit-backend}
domainname: ${BACKEND_DOMAIN:-convex.stpeteit.com}
networks: ['${NETWORK:-nginx-bridge}']
#user: '1000:1000'
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
volumes: [./data:/convex/data:z]
labels: ['com.centurylinklabs.watchtower.enable=true']
environment:
- INSTANCE_NAME
#- INSTANCE_SECRET
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${BACKEND_PORT:-3210}}
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${SITE_PROXY_PORT:-3211}}
- DISABLE_BEACON=${DISABLE_BEACON:-true}
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true}
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false}
- POSTGRES_URL=${POSTGRES_URL}
stdin_open: true
tty: true
restart: unless-stopped
healthcheck:
test: curl -f http://localhost:3210/version
interval: 5s
start_period: 10s
stop_grace_period: 10s
stop_signal: SIGINT
stpeteit-dashboard:
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
container_name: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard}
hostname: ${DASHBOARD_CONTAINER_NAME:-stpeteit-dashboard}
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-convex.stpete.com}}
networks: ['${NETWORK:-nginx-bridge}']
#user: 1000:1000
#ports: ['${DASHBOARD_PORT:-6791}:6791']
labels: ['com.centurylinklabs.watchtower.enable=true']
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-stpeteit-backend}:${PORT:-3210}}
depends_on:
stpeteit-backend:
condition: service_healthy
stdin_open: true
tty: true
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
#convexmonorepo-postgresql:
#image: postgres:17
#container_name: ${POSTGRES_CONTAINER_NAME:-convexmonorepo-postgres}
#hostname: ${POSTGRES_CONTAINER_NAME:-convexmonorepo-postgres}
#domainname: postgres.${NEXT_DOMAIN:-convexmonorepo.gbrown.org}
#networks: ['${NETWORK:-nginx-bridge}']
#ports: ['5432:5432']
#environment:
#- POSTGRES_USER=${POSTGRES_USER:-convexmonorepo}
#- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
#- POSTGRES_DB=${POSTGRES_DB:-convexmonorepo_payload}
#labels: ['com.centurylinklabs.watchtower.enable=true']
#volumes: ['./volumes/postgres:/var/lib/postgresql/data:Z']
#tty: true
#stdin_open: true
#restart: unless-stopped
#healthcheck:
#test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
#start_period: 20s
#interval: 30s
#retries: 5
#timeout: 5s
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: StPeteIT
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/StPeteIT
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=900
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+88
View File
@@ -0,0 +1,88 @@
# Tools — small self-hosted utilities. VPS (ROOTLESS PODMAN) port of the home server's
# ~/Server/tools stack, migrated 2026-08-12.
#
# Rootless adaptations:
# - :Z on every bind mount (SELinux Enforcing). All mounts here are exclusive to a
# single container, so uppercase :Z is correct -- see AGENTS.md §6.
# - No ports published: NPM proxies to each container by name over nginx-bridge.
#
# All four images start as root and drop privileges internally, so no userns_mode is
# needed (AGENTS.md §3.2).
networks:
nginx-bridge:
external: true
services:
convertx:
image: ghcr.io/c4illin/convertx:latest
container_name: convertx
hostname: convertx
domainname: convert.tools.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- ALLOW_UNAUTHENTICATED=true
- ACCOUNT_REGISTRATION=false
- HIDE_HISTORY=true
volumes:
- ./volumes/convertx:/app/data:Z
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
restart: unless-stopped
it-tools:
image: docker.io/corentinth/it-tools:latest
container_name: it-tools
hostname: it-tools
domainname: tools.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
restart: unless-stopped
pdf-tools:
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
container_name: pdf-tools
hostname: pdf-tools
domainname: pdf.tools.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- LANGS=en_US
- DOCKER_ENABLE_SECURITY=true
volumes:
- ./volumes/pdf-tools/trainingData:/usr/share/tessdata:Z
- ./volumes/pdf-tools/extraConfigs:/configs:Z
- ./volumes/pdf-tools/customFiles:/customFiles:Z
- ./volumes/pdf-tools/logs:/logs/:Z
- ./volumes/pdf-tools/pipeline:/pipeline/:Z
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
restart: unless-stopped
bento-pdf:
image: ghcr.io/alam00000/bentopdf:latest
container_name: bento-pdf
hostname: bento-pdf
domainname: bento.tools.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PUID=1000
- PGID=1000
- VITE_BRAND_NAME="GibPDF"
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:8080']
interval: 30s
timeout: 10s
retries: 3
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Tools
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Tools
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+26
View File
@@ -0,0 +1,26 @@
networks:
nginx-bridge:
external: true
services:
uptime:
image: louislam/uptime-kuma:latest
container_name: uptime
hostname: uptime
domainname: uptime.gbrown.org
networks: [nginx-bridge]
environment:
- TZ=America/New_York
volumes:
- ./volume:/app/data:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock
labels:
- "com.centurylinklabs.watchtower.enable=true"
security_opt: ['seccomp:unconfined','label:disable']
# WireGuard address only — this admin UI has no business on the public
# interface. It has its own login and the Hetzner firewall does not admit this
# port, so this is defence in depth: it removes the dependency on a firewall rule
# set that lives in a web console. See AGENTS.md §13.2.
ports: ['192.168.2.2:3001:3001']
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Uptime
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Uptime
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=300
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+22
View File
@@ -0,0 +1,22 @@
API_RATE_LIMIT=1
AUTH_AUTHENTIK_ID=CHANGE_ME
AUTH_AUTHENTIK_SECRET=CHANGE_ME
AUTH_AUTHENTIK_ISSUER="https://auth.gbrown.org/application/o/gibsend/"
AWS_ACCESS_KEY=CHANGE_ME
AWS_SECRET_KEY=CHANGE_ME
AWS_DEFAULT_REGION=us-east-2
DATABASE_URL=CHANGE_ME
GITHUB_ID=Ov23liz34colyJed9t9S
GITHUB_SECRET=CHANGE_ME
MINIO_ROOT_USER=usesend
MINIO_ROOT_PASSWORD=CHANGE_ME
NEXTAUTH_SECRET=CHANGE_ME
NEXT_PUBLIC_IS_CLOUD=false
POSTGRES_USER=usesend
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=usesend
REDIS_URL=redis://usesend-redis:6379
SMTP_HOST=smtp.usesend.gbrown.org
SMTP_USER=usesend
USESEND_PORT=3000
USESEND_URL=https://usesend.gbrown.org
+88
View File
@@ -0,0 +1,88 @@
name: usesend
networks:
nginx-bridge:
external: true
services:
redis:
image: redis:7
container_name: usesend-redis
hostname: usesend-redis
networks:
- nginx-bridge
env_file: .env
labels:
com.centurylinklabs.watchtower.enable: 'true'
volumes:
- ./volumes/redis:/data:Z
command:
- redis-server
- --maxmemory-policy
- noeviction
tty: true
stdin_open: true
restart: unless-stopped
minio:
image: minio/minio
container_name: usesend-storage
hostname: usesend-storage
networks:
- nginx-bridge
env_file: .env
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
labels:
com.centurylinklabs.watchtower.enable: 'true'
volumes:
- ./volumes/minio:/data:Z
entrypoint: sh
command: -c 'mkdir -p /data/unsend && minio server /data --console-address ':9001' --address ':9002''
tty: true
stdin_open: true
restart: unless-stopped
smtp-server:
image: usesend/smtp-proxy:v1.6.6
container_name: usesend-smtp-server
hostname: usesend-smtp
networks:
- nginx-bridge
env_file: .env
environment:
- SMTP_AUTH_USERNAME=${SMTP_USER:?err}
- USESEND_BASE_URL=${USESEND_URL:?err}
labels:
com.centurylinklabs.watchtower.enable: 'true'
tty: true
stdin_open: true
restart: unless-stopped
usesend:
image: usesend/usesend:v1.6.6
container_name: usesend
hostname: usesend
domainname: ${USESEND_URL:?err}
networks:
- nginx-bridge
env_file: .env
environment:
- PORT=${USESEND_PORT:-3000}
- DATABASE_URL=${DATABASE_URL:?err}
- NEXTAUTH_URL=${USESEND_URL:?err}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?err}
- AWS_ACCESS_KEY=${AWS_ACCESS_KEY:?err}
- AWS_SECRET_KEY=${AWS_SECRET_KEY:?err}
- AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err}
- GITHUB_ID=${GITHUB_ID:?err}
- GITHUB_SECRET=${GITHUB_SECRET:?err}
- REDIS_URL=${REDIS_URL:?err}
- NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false}
- API_RATE_LIMIT=${API_RATE_LIMIT:-1}
- SMTP_HOST=${SMTP_HOST:-smtp.usesend.gbrown.org}
- SMTP_USER=${SMTP_USER:-usesend}
labels:
com.centurylinklabs.watchtower.enable: 'true'
depends_on:
redis:
condition: service_started
tty: true
stdin_open: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: UseSend
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/UseSend
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=900
TimeoutStopSec=90
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
@@ -0,0 +1 @@
ADMIN_TOKEN=CHANGE_ME
+55
View File
@@ -0,0 +1,55 @@
# Vaultwarden — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/vaultwarden,
# staged 2026-08-12.
#
# ⚠️ STAGED, NOT AUTHORITATIVE. The home instance is still live and still serving
# vault.gbrown.org. This copy holds a point-in-time snapshot of the SQLite database.
# DO NOT log into this one and add credentials before cutover -- the two would diverge
# and one side's changes would be lost.
#
# Cutover procedure (do it in this order, it matters):
# 1. stop vaultwarden on the home server (clean SQLite, no torn WAL)
# 2. rsync -a server.gib:~/Server/vaultwarden/volume/ ~/Server/Vaultwarden/volume/
# 3. systemctl --user restart podman-vaultwarden
# 4. point vault.gbrown.org at the VPS, add the NPM proxy host
# 5. verify a login + an item decrypts, THEN leave home stopped
#
# The SQLite copy taken while home was running is crash-consistent only. Step 1 is what
# makes it clean -- don't skip it.
#
# Rootless adaptations:
# - :Z on the data mount (SELinux Enforcing).
# - /etc/localtime mount dropped in favour of TZ (AGENTS.md §8).
# - No published port; NPM proxies to http://vaultwarden:80 over nginx-bridge.
# - Image starts as root and drops privileges internally, so no userns_mode needed.
#
# NOTE: ADMIN_TOKEN is inline here, matching the home server's file verbatim. It grants
# access to /admin. Worth moving to a mode-600 .env at some point -- compose files get
# rsynced offsite by ~/Server/backup.
networks:
nginx-bridge:
external: true
services:
vaultwarden:
image: docker.io/vaultwarden/server:latest
container_name: vaultwarden
hostname: vaultwarden
domainname: vault.gbrown.org
networks:
- nginx-bridge
environment:
- TZ=America/New_York
- DOMAIN=https://vault.gbrown.org
- LOGIN_RATELIMIT_MAX_BURST=10
- LOGIN_RATELIMIT_SECONDS=60
- WEB_VAULT_ENABLED=true
- SIGNUPS_ALLOWED=false
- WEBSOCKET_ENABLED=true
- ADMIN_TOKEN=${ADMIN_TOKEN}
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volume:/data:Z
tty: true
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
RequiresMountsFor=/home/gib/Media
Description=Podman Compose: Vaultwarden
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Vaultwarden
ExecStart=/usr/bin/podman compose up -d
ExecStop=/usr/bin/podman compose down
RemainAfterExit=yes
TimeoutStartSec=600
TimeoutStopSec=60
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# update-containers — pull new images and restart the services that changed.
#
# REPLACES WATCHTOWER. Written 2026-08-17 after watchtower took gitea down for
# three days.
#
# WHY NOT WATCHTOWER: podman-compose puts every project in a POD. Watchtower does
# not update through compose — it builds a replacement container from the image
# plus the old container's config, and that replacement lands OUTSIDE the pod.
# On 2026-08-14 it did exactly that to gitea, reported `failed=0 updated=1`, and
# left it returning 502 until someone noticed three days later.
#
# This script never touches containers directly. It pulls the image and then
# restarts the SYSTEMD UNIT, so podman-compose rebuilds the project the same way
# it does at boot — pod, network aliases, published ports and all.
#
# ─────────────────────────────────────────────────────────────────────────────
# USAGE
# update-containers # every service except SKIP (what the timer runs)
# update-containers gitea # one service, e.g. from CI after a push
# update-containers --dry-run # show what WOULD update, change nothing
# update-containers --list # show services and their images, then exit
#
# Runs nightly at 00:00 via podman-update.timer (server/systemd/, enabled by
# setup-server). Log: ~/Server/logs/update-containers.log
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
# ═════════════════════════════════════════════════════════════════════════════
# CONFIG — this is the part you edit
# ═════════════════════════════════════════════════════════════════════════════
# Services that must NEVER update unattended. Add a service here to freeze it.
#
# postgresql — restarting it drops all databases at once, and every service
# with them. Minor 17.x bumps are safe in principle but not worth
# doing at midnight unobserved. Also: the image tag is pinned to
# pgvector/pgvector:pg17-trixie for collation reasons — an
# unattended change of base could corrupt text indexes.
# authentik — runs IRREVERSIBLE database migrations on startup. Rolling back
# the image does not roll back the schema; you would need a restore.
#
# Update these two by hand, after a pg_dump:
# podman pull <image> && systemctl --user restart podman-<name>.service
SKIP=(postgresql authentik watchtower)
# Services built locally rather than pulled. There is no registry image to check,
# so pulling is pointless — they are skipped automatically when no `image:` line
# resolves, but listing them here keeps the log quiet and the intent obvious.
LOCAL_BUILD=(bang completeuphoria sierraandtyler)
# ⚠️ NOTE ON GITEA: it is deliberately NOT in SKIP. The three-day outage was caused
# by watchtower's recreate mechanism, not by the act of updating — restarting the
# unit is the safe path this script uses. But gitea DOES run database migrations on
# version upgrades. If you would rather approve those yourself, move `gitea` into
# SKIP above; nothing else needs changing.
SERVER_DIR="$HOME/Server"
LOG_DIR="$SERVER_DIR/logs"
LOG="$LOG_DIR/update-containers.log"
# ═════════════════════════════════════════════════════════════════════════════
mkdir -p "$LOG_DIR"
DRY_RUN=0
ONLY=""
case "${1:-}" in
--dry-run) DRY_RUN=1 ;;
--list) LIST=1 ;;
--help|-h) sed -n '19,27p' "$0"; exit 0 ;;
"") ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*) ONLY="$1" ;;
esac
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG"; }
in_list() { local n="$1"; shift; local x; for x in "$@"; do [ "$x" = "$n" ] && return 0; done; return 1; }
# Units that are podman-compose services. Excludes podman's own helper units,
# which are not services and must stay disabled.
list_units() {
systemctl --user list-unit-files 'podman-*.service' --no-legend 2>/dev/null \
| awk '{print $1}' \
| grep -vE 'podman-(auto-update|restart|clean-transient|kube@|user-wait-network-online|update)' \
| sed 's/^podman-//; s/\.service$//' \
| sort
}
# Images declared in a service's compose.yml. Ignores commented lines and the
# `build:` stanza, which has no pullable image.
#
# Image tags frequently reference variables from the service's .env, e.g.
# ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
# git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest
# Those must be expanded or the pull is attempted against a literal "${...}" and
# always fails. We expand in a SUBSHELL so the .env cannot leak into this script's
# environment (these files contain database passwords and API keys).
images_for() {
local dir="$1"
[ -f "$dir/compose.yml" ] || return 0
grep -oE '^[[:space:]]*image:[[:space:]]*[^[:space:]#]+' "$dir/compose.yml" 2>/dev/null \
| sed -E 's/^[[:space:]]*image:[[:space:]]*//' \
| while IFS= read -r img; do
case "$img" in
*'${'*) ( set -a; [ -f "$dir/.env" ] && . "$dir/.env" >/dev/null 2>&1; set +a
eval "printf '%s\\n' \"$img\"" 2>/dev/null ) ;;
*) printf '%s\n' "$img" ;;
esac
done \
| grep -v '\${' \
| sort -u
}
workdir_for() {
systemctl --user show "podman-$1.service" -p WorkingDirectory --value 2>/dev/null
}
# ── --list ───────────────────────────────────────────────────────────────────
if [ "${LIST:-0}" = "1" ]; then
for svc in $(list_units); do
printf '%-22s' "$svc"
if in_list "$svc" "${SKIP[@]}"; then echo "SKIP (frozen)"; continue; fi
if in_list "$svc" "${LOCAL_BUILD[@]}"; then echo "SKIP (built locally)"; continue; fi
echo "$(images_for "$(workdir_for "$svc")" | tr '\n' ' ')"
done
exit 0
fi
# ── main ─────────────────────────────────────────────────────────────────────
# ${DRY_RUN:+...} would expand even when DRY_RUN=0, because "0" is a non-empty
# string. Build the label explicitly instead.
dry_label=""; [ "$DRY_RUN" -eq 1 ] && dry_label=" [DRY RUN]"
log "=== update run started${ONLY:+ (single service: $ONLY)}$dry_label ==="
updated=(); failed=(); checked=0
for svc in $(list_units); do
[ -n "$ONLY" ] && [ "$svc" != "$ONLY" ] && continue
if [ -z "$ONLY" ]; then
in_list "$svc" "${SKIP[@]}" && { log " $svc: frozen (SKIP list)"; continue; }
in_list "$svc" "${LOCAL_BUILD[@]}" && continue
fi
# ⚠️ NEVER resurrect a service that is deliberately stopped. `systemctl restart`
# STARTS an inactive unit — without this guard, a new n8n image would silently
# bring n8n back up months after it was intentionally shut down.
if [ "$(systemctl --user is-active "podman-$svc.service")" != "active" ]; then
[ -n "$ONLY" ] && log " $svc: unit is not active — refusing to start it"
continue
fi
dir="$(workdir_for "$svc")"
[ -d "$dir" ] || { log " $svc: no working directory, skipped"; continue; }
mapfile -t imgs < <(images_for "$dir")
[ "${#imgs[@]}" -eq 0 ] && continue
checked=$((checked+1))
changed=0
for img in "${imgs[@]}"; do
# A digest-pinned image can never change; pulling it is wasted work.
case "$img" in *@sha256:*) continue ;; esac
if ! podman pull -q "$img" >/dev/null 2>&1; then
log " $svc: pull failed for $img (private registry? built locally?)"
continue
fi
disk_id="$(podman image inspect "$img" --format '{{.Id}}' 2>/dev/null)"
[ -n "$disk_id" ] || continue
# Compare the image on disk against what each RUNNING CONTAINER is actually
# using — not against the pre-pull image ID.
#
# Comparing before/after the pull looks equivalent but is subtly broken: a
# --dry-run (or any earlier pull) fetches the new image, so a later real run
# sees before == after, concludes "no change", and never restarts. The
# service then runs the OLD image forever with the new one sitting on disk,
# invisible. Anchoring on the running container makes the check idempotent
# and self-healing — it reports drift no matter who pulled, or when.
#
# ⚠️ Use `inspect .Image`, NOT `ps --format {{.ImageID}}`. The former returns
# the full 64-char ID matching `image inspect .Id`; the latter returns a
# 12-char short ID, so comparing them is ALWAYS unequal and every service
# looks like it needs an update.
while IFS='|' read -r cname cimgname cimgid; do
[ "$cimgname" = "$img" ] || continue
if [ "$cimgid" != "$disk_id" ]; then
log " $svc: $cname on ${cimgid:0:12} -> new image ${disk_id:0:12} ($img)"
changed=1
fi
done < <(podman ps --filter "label=com.docker.compose.project=$svc" --format '{{.Names}}' 2>/dev/null \
| while IFS= read -r n; do
podman inspect "$n" --format '{{.Name}}|{{.ImageName}}|{{.Image}}' 2>/dev/null
done)
done
[ "$changed" -eq 0 ] && continue
if [ "$DRY_RUN" -eq 1 ]; then
log " $svc: would restart (dry run)"
updated+=("$svc")
continue
fi
# THE IMPORTANT LINE: restart the unit so podman-compose rebuilds the project
# (pod, networks, aliases, ports). Never recreate the container directly.
if systemctl --user restart "podman-$svc.service" 2>>"$LOG"; then
sleep 5
if [ "$(systemctl --user is-active "podman-$svc.service")" = "active" ]; then
log " $svc: RESTARTED ok"
updated+=("$svc")
else
log " $svc: ⚠ RESTARTED BUT UNIT NOT ACTIVE"
failed+=("$svc")
fi
else
log " $svc: ⚠ RESTART FAILED"
failed+=("$svc")
fi
done
# Reclaim layers the old images left behind. Dangling only — never touches an
# image a container still references.
if [ "$DRY_RUN" -eq 0 ] && [ "${#updated[@]}" -gt 0 ]; then
freed="$(podman image prune -f 2>/dev/null | tail -1)"
[ -n "$freed" ] && log " pruned: $freed"
fi
log "=== done: $checked checked, ${#updated[@]} updated${updated:+ (${updated[*]})}, ${#failed[@]} failed${failed:+ (${failed[*]})} ==="
# Non-zero if anything failed, so the systemd unit shows as failed and
# `systemctl --user --failed` surfaces it.
[ "${#failed[@]}" -eq 0 ]
+22
View File
@@ -0,0 +1,22 @@
[Unit]
# Nightly image updates. Replaces watchtower, which recreated containers outside
# their podman-compose pod and took gitea down for three days — see the header
# of server/scripts/update-containers.
Description=Pull new container images and restart changed services
Documentation=https://git.gbrown.org/gib/Panama
# Needs the network and the podman socket path to exist.
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# %h rather than a literal home so the unit works for whichever user linked it.
# The one assumption is the standard checkout path; a machine with PANAMA_PATH
# somewhere else edits this line, and setup-server will say so when the script
# is not where this points.
ExecStart=%h/.local/share/Panama/server/scripts/update-containers
# Pulling ~20 images over a slow registry can take a while; do not kill it early.
TimeoutStartSec=3600
# The script logs to ~/Server/logs/update-containers.log as well as the journal.
StandardOutput=journal
StandardError=journal
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Nightly container image updates at midnight
Documentation=https://git.gbrown.org/gib/Panama
[Timer]
# Midnight local time.
OnCalendar=*-*-* 00:00:00
# If the machine was off at midnight, run once after it comes back rather than
# skipping the day entirely.
Persistent=true
# Avoid every timer on the box firing at the same instant.
RandomizedDelaySec=300
[Install]
# User timers need linger, which setup-server enables.
WantedBy=timers.target