Phase 7: Cosmos deployment

- Dockerfile with a check target (typecheck and tests) and one image per
  service on a pinned Node 22.20 Alpine base, each with only its own
  production dependencies and a health check.
- deploy/compose.yaml: gk-content, gk-admin and gk-site on homelab_network
  with no host ports, read-only root filesystems, no capabilities, the vault's
  Site folder mounted into the content service, and backup.dump=sqlite on it.
- deploy/deploy.sh builds and checks the commit, restarts the stack, and goes
  back to the last good tag if it doesn't come up healthy; rollback.sh swaps
  tags. .gitea/workflows/deploy.yml runs it on a host runner on Cosmos.
- docs/deploy.md runbook (settings, runner, mirror, NPM hosts, Turnstile,
  Healthchecks, backups, cutover) and the stack's inventory note in vault-kit.
- Site: trusts X-Forwarded-Host/Proto for gregkilmer.com hosts so form posts
  pass Astro's origin check behind the proxy; rate limits read
  CF-Connecting-IP; site URL set for absolute links.
- Content service: the held-change webhook carries a Discord-ready message;
  .env.example lists the core settings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-12 00:43:03 +00:00
parent f9a8fb1acd
commit 754c6482be
17 changed files with 472 additions and 5 deletions
+3
View File
@@ -0,0 +1,3 @@
# Admin page settings. On Cosmos: /home/greg/homelab/gregkilmer/admin.env
# The content service's admin token (the admin:… entry in content.env's CONTENT_TOKENS).
ADMIN_TOKEN=
+66
View File
@@ -0,0 +1,66 @@
# The GregKilmer.com stack on Cosmos: content service, admin page and public site.
#
# Lives at /home/greg/homelab/gregkilmer/compose.yaml (the Gitea Actions deploy
# copies it there from deploy/compose.yaml on every build). Settings:
# .env stack settings: GK_TAG, VAULT_SITE, PUID, PGID (deploy/stack.env.example)
# content.env content service secrets (services/content/.env.example)
# admin.env admin page settings (deploy/admin.env.example)
# site.env public site secrets (services/site/.env.example)
#
# No service publishes a host port. Nginx Proxy Manager reaches them by
# container name on homelab_network. See docs/deploy.md.
name: gregkilmer
x-common: &common
restart: unless-stopped
user: "${PUID:-1000}:${PGID:-1000}"
networks: [homelab_network]
read_only: true
tmpfs: [/tmp]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
services:
gk-content:
<<: *common
image: gregkilmer/content:${GK_TAG:?set GK_TAG in .env}
container_name: gk-content
env_file: content.env
environment:
SITE_DIR: /vault/Site
DATA_DIR: /data
volumes:
# The vault's Site folder. Read-write: accepted API and MCP changes are
# written back into the notes.
- ${VAULT_SITE:?set VAULT_SITE in .env}:/vault/Site
# content.db (notes, history, review queue, saved forms, sync cache) and
# submissions/ (form attachments). Both are backed up.
- ./data/content:/data
labels:
backup.dump: sqlite
gk-admin:
<<: *common
image: gregkilmer/admin:${GK_TAG}
container_name: gk-admin
env_file: admin.env
environment:
CONTENT_URL: http://gk-content:4310
depends_on:
gk-content: { condition: service_healthy }
gk-site:
<<: *common
image: gregkilmer/site:${GK_TAG}
container_name: gk-site
env_file: site.env
environment:
CONTENT_URL: http://gk-content:4310
depends_on:
gk-content: { condition: service_healthy }
networks:
homelab_network:
name: homelab_network
external: true
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Builds the three images for the checked-out commit, runs the checks, and
# restarts the stack on it. If the new containers don't come up healthy, the
# stack goes back to the last good tag. Run from the repo root on Cosmos, by the
# Gitea Actions workflow or by hand. See docs/deploy.md.
set -euo pipefail
STACK="${GK_STACK_DIR:-/home/greg/homelab/gregkilmer}"
ENV_FILE="$STACK/.env"
TAG="$(git rev-parse --short=12 HEAD)"
compose() { docker compose --project-directory "$STACK" -f "$STACK/compose.yaml" "$@"; }
setting() { grep -E "^$1=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- || true; }
set_setting() {
if grep -qE "^$1=" "$ENV_FILE"; then sed -i "s|^$1=.*|$1=$2|" "$ENV_FILE"; else echo "$1=$2" >> "$ENV_FILE"; fi
}
for f in .env content.env admin.env site.env; do
[ -f "$STACK/$f" ] || { echo "Missing $STACK/$f (see docs/deploy.md)"; exit 1; }
done
CURRENT="$(setting GK_TAG)"
PREVIOUS="$(setting GK_PREVIOUS_TAG)"
if [ "$CURRENT" = "$TAG" ] && [ "${FORCE:-}" != "1" ]; then
echo "Already running $TAG; nothing to do (FORCE=1 to rebuild)."
exit 0
fi
echo "== Checks"
docker build --target check .
echo "== Images for $TAG"
for t in content admin site; do
docker build --target "$t" -t "gregkilmer/$t:$TAG" .
done
echo "== Deploy"
mkdir -p "$STACK/data/content"
cp deploy/compose.yaml "$STACK/compose.yaml"
set_setting GK_TAG "$TAG"
[ -n "$CURRENT" ] && set_setting GK_PREVIOUS_TAG "$CURRENT"
if ! compose up -d --remove-orphans --wait --wait-timeout 120; then
echo "!! $TAG did not come up healthy"
compose ps
compose logs --tail 50 || true
if [ -n "$CURRENT" ]; then
echo "== Rolling back to $CURRENT"
set_setting GK_TAG "$CURRENT"
set_setting GK_PREVIOUS_TAG "$PREVIOUS"
compose up -d --wait --wait-timeout 120
fi
exit 1
fi
echo "== Prune old images (keeping $TAG and ${CURRENT:-none})"
for t in content admin site; do
docker images "gregkilmer/$t" --format '{{.Tag}}' | while read -r old; do
[ "$old" = "$TAG" ] || [ "$old" = "$CURRENT" ] || docker rmi "gregkilmer/$t:$old" >/dev/null || true
done
done
echo "Deployed $TAG"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Puts the stack back on the previous tag (GK_PREVIOUS_TAG) and swaps the two,
# so running it again rolls forward. See docs/deploy.md.
set -euo pipefail
STACK="${GK_STACK_DIR:-/home/greg/homelab/gregkilmer}"
ENV_FILE="$STACK/.env"
cur="$(grep -E '^GK_TAG=' "$ENV_FILE" | cut -d= -f2-)"
prev="$(grep -E '^GK_PREVIOUS_TAG=' "$ENV_FILE" | cut -d= -f2-)"
[ -n "$prev" ] || { echo "No GK_PREVIOUS_TAG in $ENV_FILE"; exit 1; }
sed -i "s|^GK_TAG=.*|GK_TAG=$prev|; s|^GK_PREVIOUS_TAG=.*|GK_PREVIOUS_TAG=$cur|" "$ENV_FILE"
docker compose --project-directory "$STACK" -f "$STACK/compose.yaml" up -d --wait --wait-timeout 120
echo "Now running $prev (previous: $cur)"
+9
View File
@@ -0,0 +1,9 @@
# Stack settings for compose.yaml. On Cosmos: /home/greg/homelab/gregkilmer/.env
# The Gitea Actions deploy rewrites GK_TAG on every build; GK_PREVIOUS_TAG is the rollback.
GK_TAG=
GK_PREVIOUS_TAG=
# The vault's Site folder on the host.
VAULT_SITE=/home/greg/homelab/obsidian-vault/20-29_Makerspace/24_Homelab_Infrastructure/24.01_PersonalWebsite/Site
# The user the containers run as: the owner of the vault files (id -u greg, id -g greg).
PUID=1000
PGID=1000