Snapshot both servers: the VPS catalog moves to server/vps, the home server joins as server/home

The catalog in server/containers had drifted from the VPS. Eleven compose
files and twelve units no longer matched what runs, and two running
services, Gbrown_Agent and BillTracker, were missing. server/vps is now a
copy of the live files, and panama-server reads from it.

server/home holds the home server's services in the same shape. They still
run under Docker, so the podman units there are drafts for a later move, and
nothing installs from that tree. PostHog, Sentry and Plausible are checkouts
of upstream repositories. Only their local files are kept, next to an
UPSTREAM file naming the repository and the exact commit.

Every secret in both trees is a ${VAR} reference named in .env.example, or
CHANGE_ME in files that are not compose files. The live servers still hold
some of those values inline, so a live file can differ from its copy on
those lines alone. Existing .env.example files keep their comments, with new
keys appended at the end.

The ignore rules and the secrets contract now cover both trees, plus the
volume/ and volumes/ directories the home server uses.
This commit is contained in:
Gabriel Brown committed 2026-10-06 10:52:47 -04:00
1 parent a664cf68f5
commit 913a57f8a6
146 files changed
+5258 -354

No files matched your search

+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
+21
View File
@@ -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
+25
View File
@@ -0,0 +1,25 @@
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]
# Also set on the live machine
AUTHENTIK_IMAGE=CHANGE_ME
PG_DB=CHANGE_ME
PG_USER=CHANGE_ME
+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,20 @@
[Unit]
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
+75
View File
@@ -0,0 +1,75 @@
# 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
# Login is SSO-only through Authentik (OIDC provider "Gibs Auth", app slug
# beszel). The OAuth config lives in data.db (users collection options),
# not here. The PocketBase superuser panel at /_/ keeps its own password,
# and is the way back in if Authentik is down: flip this to false there.
- DISABLE_PASSWORD_AUTH=true
# 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
+20
View File
@@ -0,0 +1,20 @@
[Unit]
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
+14
View File
@@ -0,0 +1,14 @@
INSTANCE_NAME=CHANGE_ME
INSTANCE_SECRET=CHANGE_ME
POSTGRES_URL=CHANGE_ME
CONVEX_CLOUD_ORIGIN=CHANGE_ME
CONVEX_SITE_ORIGIN=CHANGE_ME
DISABLE_BEACON=CHANGE_ME
REDACT_LOGS_TO_CLIENT=CHANGE_ME
DO_NOT_REQUIRE_SSL=CHANGE_ME
MCP_SECRET=CHANGE_ME
CONVEX_BACKEND_IMAGE=CHANGE_ME
CONVEX_DASHBOARD_IMAGE=CHANGE_ME
MCP_PUBLIC_URL=CHANGE_ME
NEXT_PUBLIC_CONVEX_URL=CHANGE_ME
NEXT_PUBLIC_SITE_URL=CHANGE_ME
+69
View File
@@ -0,0 +1,69 @@
name: bill-tracker
networks:
nginx-bridge:
external: true
services:
bill-tracker-next:
image: git.gbrown.org/gib/bill-tracker-next:latest
container_name: bill-tracker-next
hostname: bill-tracker-next
domainname: billtracker.gbrown.org
networks: [nginx-bridge]
environment:
NODE_ENV: production
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://billtracker.gbrown.org}
NEXT_PUBLIC_CONVEX_URL: ${NEXT_PUBLIC_CONVEX_URL:-https://api.billtracker.gbrown.org}
restart: unless-stopped
bill-tracker-mcp:
image: git.gbrown.org/gib/bill-tracker-mcp:latest
container_name: bill-tracker-mcp
hostname: bill-tracker-mcp
domainname: mcp.billtracker.gbrown.org
networks: [nginx-bridge]
volumes: [./data/mcp:/data:Z]
environment:
NODE_ENV: production
MCP_PUBLIC_URL: ${MCP_PUBLIC_URL:-https://mcp.billtracker.gbrown.org}
MCP_SECRET: ${MCP_SECRET:?Set MCP_SECRET in the VPS .env}
BILL_TRACKER_CONVEX_URL: ${NEXT_PUBLIC_CONVEX_URL:-https://api.billtracker.gbrown.org}
restart: unless-stopped
bill-tracker-backend:
image: ${CONVEX_BACKEND_IMAGE:-ghcr.io/get-convex/convex-backend@sha256:1f2044e3eac463ac78973b136c0baf72d4ada602611d853d6f99f280e29e0a98}
container_name: bill-tracker-backend
hostname: bill-tracker-backend
domainname: api.billtracker.gbrown.org
networks: [nginx-bridge]
volumes: [./data/convex:/convex/data:Z]
environment:
INSTANCE_NAME: ${INSTANCE_NAME:-bill_tracker}
INSTANCE_SECRET: ${INSTANCE_SECRET:?Set INSTANCE_SECRET in the VPS .env}
POSTGRES_URL: ${POSTGRES_URL:?Set POSTGRES_URL in the VPS .env}
CONVEX_CLOUD_ORIGIN: https://api.billtracker.gbrown.org
CONVEX_SITE_ORIGIN: https://convex.billtracker.gbrown.org
DISABLE_BEACON: 'true'
REDACT_LOGS_TO_CLIENT: 'true'
DO_NOT_REQUIRE_SSL: 'true'
restart: unless-stopped
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3210/version']
interval: 5s
start_period: 10s
stop_grace_period: 10s
stop_signal: SIGINT
bill-tracker-dashboard:
image: ${CONVEX_DASHBOARD_IMAGE:-ghcr.io/get-convex/convex-dashboard@sha256:284a2638e0c1a4ec0c2327d8219776f3a426ca5824b81686ae4d9454dc0ce8ed}
container_name: bill-tracker-dashboard
hostname: bill-tracker-dashboard
domainname: dashboard.billtracker.gbrown.org
networks: [nginx-bridge]
environment:
NEXT_PUBLIC_DEPLOYMENT_URL: https://api.billtracker.gbrown.org
depends_on:
bill-tracker-backend:
condition: service_healthy
restart: unless-stopped
@@ -0,0 +1,21 @@
[Unit]
Description=Podman Compose: Bill Tracker
RequiresMountsFor=/home/gib/Media
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/BillTracker
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
+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 '617408b6790b546811cc38d0353ea506';
# 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=CHANGE_ME
labels:
com.centurylinklabs.watchtower.enable: "true"
tty: true
stdin_open: true
restart: unless-stopped
+21
View File
@@ -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
+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 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=
# Also set on the live machine
BACKEND_PORT=CHANGE_ME
PORT=CHANGE_ME
SITE_PROXY_PORT=CHANGE_ME
+78
View File
@@ -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 'e2bf60b34aff8ce259e5ed42da46c9c6';
# 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=CHANGE_ME
- 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
+1
View File
@@ -0,0 +1 @@
AGENT_INGEST_SECRET=CHANGE_ME
+43
View File
@@ -0,0 +1,43 @@
# Hetzner VPS (junior.gib, rootless podman): ~/Server/Gbrown_Agent/compose.yml.
# Follows ~/Server/AGENTS.md. AGENT_INGEST_SECRET lives in ./.env (never
# committed). Started by podman-gbrown-agent.service, never by hand.
#
# SELinux:
# - The podman socket is mounted with no relabel flag, ever (AGENTS.md §6).
# - Reading it needs `label:disable`, the same exception portainer and uptime
# have as container-management tooling.
# - Because labeling is off, the NPM logs mount also takes no :z or :Z.
# NPM mounts its data dir with :Z (a private MCS pair), so relabeling it here
# would lock NPM out of its own data.
networks:
nginx-bridge:
external: true
services:
gbrown-agent:
image: git.gbrown.org/gib/gbrown-agent:latest
container_name: gbrown-agent
hostname: gbrown-agent
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- HOST_ID=vps
- INGEST_URL=https://convex.gbrown.org/ingest
- AGENT_INGEST_SECRET=${AGENT_INGEST_SECRET}
# Reach Convex over WireGuard, not Cloudflare: pin the hostname to the
# Home Server's proxy (192.168.1.2, routed via the `Home` interface). The
# URL stays https with the real hostname, so SNI and the certificate check
# are unchanged.
extra_hosts:
- 'convex.gbrown.org:192.168.1.2'
# The image runs as uid 1000 (bun). keep-id maps it to gib, who owns the
# podman socket and the NPM logs (AGENTS.md §3.2). Never `user: 1000:1000`.
userns_mode: 'keep-id:uid=1000,gid=1000'
volumes:
- /run/user/1000/podman/podman.sock:/var/run/docker.sock # no :Z, ever
- /home/gib/Server/Nginx_Proxy_Manager/volumes/data/logs:/npm-logs:ro
security_opt: ['label:disable', 'no-new-privileges:true']
read_only: true
cap_drop: [ALL]
restart: unless-stopped
stop_grace_period: 10s
@@ -0,0 +1,27 @@
# Rootless user unit for the telemetry agent on junior.gib, from
# ~/Server/podman-example.service. Install (as gib, never sudo):
# cp podman-gbrown-agent.service ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now podman-gbrown-agent.service
# Then add gbrown-agent to the lists in ~/Server/verify-rootless.sh, and to
# update-containers.sh if it should follow nightly image updates.
[Unit]
Description=Podman Compose: gbrown telemetry agent
After=network-online.target podman.socket
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=3
[Service]
Type=oneshot
WorkingDirectory=%h/Server/Gbrown_Agent
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 @@
USESEND_API_KEY=CHANGE_ME
+41
View File
@@ -0,0 +1,41 @@
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
# ── Mailer ────────────────────────────────────────────────────────────────
# The image's entrypoint runs `environment-to-ini`, so GITEA__section__KEY
# vars are written into /data/gitea/conf/app.ini on every start. This block
# is therefore the source of truth -- hand-editing app.ini gets overwritten
# on the next restart. It also means PASSWD lands in app.ini in plaintext
# inside the volume; that is how Gitea works, not a misconfiguration.
#
# PROTOCOL=smtp+starttls matches port 587. The relay refuses AUTH until
# STARTTLS succeeds, so plain `smtp` would fail with 538.
- GITEA__mailer__ENABLED=true
- GITEA__mailer__PROTOCOL=smtp+starttls
- GITEA__mailer__SMTP_ADDR=smtp.usesend.gbrown.org
- GITEA__mailer__SMTP_PORT=587
- GITEA__mailer__USER=usesend
- GITEA__mailer__PASSWD=${USESEND_API_KEY:?set USESEND_API_KEY in Gitea/.env}
- GITEA__mailer__FROM=Gitea <[email protected]>
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
+21
View File
@@ -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
+30
View File
@@ -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,20 @@
[Unit]
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
+145
View File
@@ -0,0 +1,145 @@
# 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
# Also set on the live machine
CLIENT_GITLAB_LOGIN_URL=CHANGE_ME
+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,20 @@
[Unit]
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
+72
View File
@@ -0,0 +1,72 @@
# 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
# Also set on the live machine
NEXT_PUBLIC_POSTHOG_KEY=CHANGE_ME
NEXT_PUBLIC_POSTHOG_HOST=CHANGE_ME
NEXT_PUBLIC_PLAUSIBLE_SRC=CHANGE_ME
BACKEND_PORT=CHANGE_ME
PORT=CHANGE_ME
SITE_PROXY_PORT=CHANGE_ME
+92
View File
@@ -0,0 +1,92 @@
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}
# Upstream DNS pinned to Cloudflare: Hetzner resolvers held a stale
# NXDOMAIN for api.lashaddictstudio.com for hours after it was created
# (2026-09-30). Container names still resolve through aardvark-dns.
dns:
- 1.1.1.1
- 1.0.0.1
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}
# Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend
# fetches its own auth discovery URL, which Hetzner resolvers failed.
dns:
- 1.1.1.1
- 1.0.0.1
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}
# Same Cloudflare DNS pin as lashaddict-next (see its comment): the backend
# fetches its own auth discovery URL, which Hetzner resolvers failed.
dns:
- 1.1.1.1
- 1.0.0.1
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
+20
View File
@@ -0,0 +1,20 @@
[Unit]
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,51 @@
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_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,20 @@
[Unit]
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
+7
View File
@@ -0,0 +1,7 @@
PENPOT_SECRET_KEY=CHANGE_ME
PENPOT_DATABASE_PASSWORD=CHANGE_ME
# Also set on the live machine
PENPOT_SMTP_PASSWORD=CHANGE_ME
PENPOT_OIDC_CLIENT_ID=CHANGE_ME
PENPOT_OIDC_CLIENT_SECRET=CHANGE_ME
+183
View File
@@ -0,0 +1,183 @@
# Penpot — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12.
#
# First started 2026-09-21. Role + database created on the SHARED postgres, and the
# same lines added to PostgreSQL/initdb/00-roles-and-databases.sql.template (note the
# .template suffix -- the plain .sql path this header used to cite does not exist).
# Managed by: systemctl --user enable --now podman-penpot.service
#
# Image tags are PINNED and watchtower is disabled on purpose. Penpot runs schema
# migrations on boot and has shipped breaking major upgrades (1.x -> 2.x needed manual
# steps), so it is a bad candidate for unattended updates. Bump the tags deliberately,
# and back up the assets volume WITH the database -- a pg_dump alone will not restore.
#
# tty/stdin_open are false on every service here. A TTY makes tools in an entrypoint
# think they are interactive; usesend hung silently on exactly that (2026-09-19).
#
# 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).
#
# Public sign-up is off (disable-registration). Accounts come from Authentik SSO
# instead: enable-oidc-registration creates a profile on first login.
#
# NPM: proxy penpot.gbrown.org -> http://penpot-frontend:8080
#
# MCP (added 2026-09-23): `enable-mcp` on the FRONTEND makes its entrypoint write
# /mcp/stream, /mcp/sse and /mcp/ws locations proxying to penpot-mcp:4401 (HTTP) and
# :4402 (plugin websocket), so NPM needs no extra config. The flag is also on the
# backend, matching upstream, where it gates per-user MCP keys (Account > Integrations).
# Claude Code connects to https://penpot.gbrown.org/mcp/stream?userToken=<key>.
networks:
nginx-bridge:
external: true
services:
penpot-frontend:
image: docker.io/penpotapp/frontend:2.17.2
container_name: penpot-frontend
hostname: penpot-frontend
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
- PENPOT_FLAGS=disable-registration disable-login-with-password enable-login-with-oidc enable-oidc-registration enable-smtp enable-mcp
labels:
com.centurylinklabs.watchtower.enable: "false"
volumes:
- ./volumes/assets:/opt/data/assets:z
depends_on:
- penpot-backend
- penpot-exporter
- penpot-mcp
tty: false
stdin_open: false
restart: unless-stopped
penpot-backend:
image: docker.io/penpotapp/backend:2.17.2
container_name: penpot-backend
hostname: penpot-backend
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
# Heap deliberately UNCAPPED (Gib's call, 2026-09-21): the JVM takes ~25% of
# host RAM, measuring ~2.3GB resident. Box has 16GB and the priority is
# responsiveness, not footprint. If it gets tight, Penpot moves to the home
# server rather than getting squeezed here. Set JVM_OPTS=-Xmx<n> to cap.
- PENPOT_FLAGS=disable-registration disable-login-with-password enable-login-with-oidc enable-oidc-registration enable-smtp enable-prepl-server enable-mcp
- PENPOT_PUBLIC_URI=https://penpot.gbrown.org
# ── SMTP via the UseSend stack on this same host ──────────────────────
# The proxy ADVERTISES "AUTH LOGIN PLAIN" before STARTTLS but actually refuses
# it ("538 Must issue a STARTTLS command first"), so TLS is mandatory. It is
# reached by the smtp.usesend.gbrown.org network alias rather than its
# container name so the wildcard cert's hostname check passes.
# Username is the proxy's SMTP_AUTH_USERNAME; password is a UseSend API key.
# Max message size is 10MB.
- PENPOT_SMTP_HOST=smtp.usesend.gbrown.org
- PENPOT_SMTP_PORT=587
- PENPOT_SMTP_USERNAME=usesend
- PENPOT_SMTP_PASSWORD=${PENPOT_SMTP_PASSWORD:?set this in .env -- a UseSend API key}
- PENPOT_SMTP_TLS=true
- PENPOT_SMTP_SSL=false
- PENPOT_SMTP_DEFAULT_FROM=Penpot <[email protected]>
- PENPOT_SMTP_DEFAULT_REPLY_TO=Penpot <[email protected]>
- 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 defaults to min-size=60 AND max-size=60 -- it eagerly opens 60
# connections and never releases them. On the SHARED postgres (max_connections
# = 100) that silently ate 60% of the cluster's slots and other apps started
# getting "remaining connection slots are reserved for SUPERUSER". Undocumented
# in Penpot's config guide; the numbers are visible in its startup log line
# `app.db - initialize connection pool ... min-size=60, max-size=60`.
- PENPOT_DATABASE_MIN_POOL_SIZE=4
- PENPOT_DATABASE_MAX_POOL_SIZE=12
- PENPOT_REDIS_URI=redis://penpot-redis/0
- PENPOT_OBJECTS_STORAGE_BACKEND=fs
- PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets
- PENPOT_TELEMETRY_ENABLED=false
# ── SSO via Authentik (app slug "penpot", any Authentik user) ────────
# Login is SSO-only (disable-login-with-password). Existing profiles match
# by email, and enable-oidc-registration creates a profile on first SSO
# login, while disable-registration keeps the public sign-up form off.
# Authority must keep its trailing slash; Penpot appends the discovery path.
- PENPOT_OIDC_BASE_URI=https://auth.gbrown.org/application/o/penpot/
- PENPOT_OIDC_CLIENT_ID=${PENPOT_OIDC_CLIENT_ID:?set this in .env}
- PENPOT_OIDC_CLIENT_SECRET=${PENPOT_OIDC_CLIENT_SECRET:?set this in .env}
- PENPOT_OIDC_SCOPES=openid profile email
labels:
com.centurylinklabs.watchtower.enable: "false"
volumes:
- ./volumes/assets:/opt/data/assets:z
depends_on:
- penpot-redis
tty: false
stdin_open: false
restart: unless-stopped
penpot-mcp:
# Upstream image defaults: `node index.js --multi-user`, listening on 0.0.0.0.
# No volumes and no published ports; only the frontend nginx talks to it.
image: docker.io/penpotapp/mcp:2.17.2
container_name: penpot-mcp
hostname: penpot-mcp
domainname: penpot.gbrown.org
networks: ['nginx-bridge']
environment:
- TZ=America/New_York
labels:
com.centurylinklabs.watchtower.enable: "false"
tty: false
stdin_open: false
restart: unless-stopped
penpot-exporter:
image: docker.io/penpotapp/exporter:2.17.2
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
# REQUIRED and missing from the original port: the exporter validates a
# :secret-key at boot and crash-loops without it. Must match the backend.
- PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY}
- PENPOT_REDIS_URI=redis://penpot-redis/0
labels:
com.centurylinklabs.watchtower.enable: "false"
depends_on:
- penpot-redis
tty: false
stdin_open: false
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: "false"
tty: false
stdin_open: false
restart: unless-stopped
+21
View File
@@ -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,20 @@
[Unit]
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
+4
View File
@@ -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,20 @@
[Unit]
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
+4
View File
@@ -0,0 +1,4 @@
RUSTDESK_API_RUSTDESK_JWT_KEY=CHANGE_ME
# Also set on the live machine
RUSTDESK_API_RUSTDESK_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=${RUSTDESK_API_RUSTDESK_KEY}
- 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,20 @@
[Unit]
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
+69
View File
@@ -0,0 +1,69 @@
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
# Also set on the live machine
PORT=CHANGE_ME
+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
+21
View File
@@ -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
+63
View File
@@ -0,0 +1,63 @@
# 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=
# Also set on the live machine
BACKEND_PORT=CHANGE_ME
PORT=CHANGE_ME
SITE_PROXY_PORT=CHANGE_ME
+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
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
# 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
+20
View File
@@ -0,0 +1,20 @@
[Unit]
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
+20
View File
@@ -0,0 +1,20 @@
[Unit]
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
+26
View File
@@ -0,0 +1,26 @@
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
# Also set on the live machine
AWS_ACCESS_KEY_ID=CHANGE_ME
AWS_SECRET_ACCESS_KEY=CHANGE_ME
+131
View File
@@ -0,0 +1,131 @@
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:latest
container_name: usesend-smtp-server
hostname: usesend-smtp
networks:
nginx-bridge:
# The proxy REQUIRES STARTTLS before it will accept AUTH, so clients must
# reach it by a name the certificate actually covers. The wildcard is
# *.gbrown.org, which matches ONE label -- hence smtp-usesend, not
# smtp.usesend.
#
# Resolution: podman's aardvark-dns answers this name authoritatively for
# containers ON nginx-bridge, handing back the container IP. NPM is not
# involved at all -- it only reverse-proxies HTTP(S), and SMTP ports are
# never published to the host, so the proxy is unreachable from outside.
#
# NOTE: there IS a wildcard *.gbrown.org record in Cloudflare, so this name
# also resolves publicly (to Cloudflare, which serves nothing for it). The
# consequence: if this container is STOPPED, aardvark-dns stops answering,
# the lookup falls through to that public record, and clients dial
# Cloudflare on 587 and hang until timeout instead of failing fast.
aliases:
# Both names resolve to this container and BOTH validate against npm-43,
# which carries *.gbrown.org and *.usesend.gbrown.org. smtp.usesend is the
# name UseSend's own UI advertises and is the target to standardise on;
# smtp-usesend stays until every consumer has been moved across.
- smtp-usesend.gbrown.org
- smtp.usesend.gbrown.org
env_file: .env
environment:
- SMTP_AUTH_USERNAME=${SMTP_USER:?err}
- USESEND_BASE_URL=${USESEND_URL:?err}
# The image's built-in default cert cannot complete a TLS 1.3 handshake at all
# ("no suitable signature algorithm"), which silently made STARTTLS -- and so
# ALL inbound SMTP -- impossible. These point it at the real wildcard cert.
- USESEND_API_CERT_PATH=/certs/fullchain.pem
- USESEND_API_KEY_PATH=/certs/privkey.pem
ports:
# Bound to the WireGuard address ONLY, never 0.0.0.0 (AGENTS.md §13.2). This is an
# authenticated relay; putting it on the public interface would invite the internet to
# brute-force it, and contradicts the Cloudflare-only posture in §7.
# The `Home` wg interface sits in firewalld's `trusted` zone, so no rule is needed.
# Rootless can bind these because net.ipv4.ip_unprivileged_port_start = 80.
# Consumers on the home server reach it as smtp-usesend.gbrown.org (UniFi DNS maps
# that name to 192.168.2.2) so the *.gbrown.org cert still validates by hostname.
- "192.168.2.2:587:587"
- "192.168.2.2:465:465"
volumes:
# Copied from NPM's letsencrypt store, NOT bind-mounted from it: NPM mounts that
# tree with :Z (private SELinux category), so a second consumer would fight it.
# TRADE-OFF: copies go stale. Refresh them when NPM renews (see certs/README).
- ./certs:/certs:ro,Z
labels:
com.centurylinklabs.watchtower.enable: 'true'
tty: true
stdin_open: true
restart: unless-stopped
usesend:
image: usesend/usesend:latest
container_name: usesend
hostname: usesend
domainname: ${USESEND_URL:?err}
networks:
- nginx-bridge
env_file: .env
environment:
- COREPACK_ENABLE_DOWNLOAD_PROMPT=0
- PORT=${USESEND_PORT:-3000}
- DATABASE_URL=${DATABASE_URL:?err}
- NEXTAUTH_URL=${USESEND_URL:?err}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?err}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:?err}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_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: false
stdin_open: false
restart: unless-stopped
+21
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
ADMIN_TOKEN=CHANGE_ME
# Also set on the live machine
USESEND_API_KEY=CHANGE_ME
SSO_CLIENT_ID=CHANGE_ME
SSO_CLIENT_SECRET=CHANGE_ME
+77
View File
@@ -0,0 +1,77 @@
# Vaultwarden — VPS (ROOTLESS PODMAN) port of the home server's ~/Server/vaultwarden,
# staged 2026-08-12.
#
# This is the live instance serving vault.gbrown.org. The home server copy is retired.
#
# 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.
#
# ADMIN_TOKEN lives in the mode-600 .env (which ~/Server/backup copies, so restores
# have it). volume/config.json also holds admin_token and OVERRIDES the env value;
# saving settings in the /admin panel writes it there. Keep the two in sync.
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:?set ADMIN_TOKEN in .env}
# ── SSO via Authentik (app slug "vaultwarden", admin group only) ─────
# SSO replaces the email step only. The vault is still unlocked with the
# master password, because that is what the vault is encrypted with.
# SSO_ONLY stays off so password login keeps working for everyone.
# Existing accounts link by email, and Authentik must send
# email_verified=true for that (its "email_custom" scope mapping does).
# The authority needs its trailing slash.
- SSO_ENABLED=true
- SSO_ONLY=false
- SSO_AUTHORITY=https://auth.gbrown.org/application/o/vaultwarden/
- SSO_SCOPES=openid profile email offline_access
- SSO_CLIENT_ID=${SSO_CLIENT_ID:?set SSO_CLIENT_ID in .env}
- SSO_CLIENT_SECRET=${SSO_CLIENT_SECRET:?set SSO_CLIENT_SECRET in .env}
# ── SMTP via the UseSend proxy on this host ───────────────────────────
# These used to live in volume/config.json (written by the admin panel).
# config.json OVERRIDES env, so those keys were removed from it; leaving
# them would silently win over everything below.
#
# Reached by the smtp.usesend.gbrown.org network alias, not the container
# name, so the *.gbrown.org wildcard cert passes hostname verification --
# which is why ACCEPT_INVALID_* can stay false.
#
# FROM must be on a UseSend-verified domain. Bare gbrown.org is NOT
# verified; vault.gbrown.org is.
- SMTP_HOST=smtp.usesend.gbrown.org
- SMTP_PORT=587
- SMTP_SECURITY=starttls
- SMTP_USERNAME=usesend
- SMTP_PASSWORD=${USESEND_API_KEY:?set USESEND_API_KEY in .env}
- [email protected]
- SMTP_FROM_NAME=Gib's Vault Admin
- SMTP_TIMEOUT=15
- SMTP_ACCEPT_INVALID_CERTS=false
- SMTP_ACCEPT_INVALID_HOSTNAMES=false
- SMTP_EMBED_IMAGES=true
labels:
com.centurylinklabs.watchtower.enable: "true"
volumes:
- ./volume:/data:Z
tty: true
restart: unless-stopped
@@ -0,0 +1,20 @@
[Unit]
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