Add shared nginx-proxy role; deploy Hermes in Docker; rename Paperclip

nginx-proxy (new role):
- Shared nginx + certbot under /opt/nginx, replacing the copies that
  lived inside the Paperclip compose project
- Apps publish via tasks_from: add_vhost (cert marker -> issue cert ->
  write vhost -> nginx -t -> reload); a vhost that fails nginx -t is
  rolled back
- Certs issued one per domain via Cloudflare DNS challenge; domains
  registered as marker files in cert-domains/; optional LE staging
- Stock nginx.conf plus conf.d (directory mounts, not single-file
  mounts); catch-all server drops unknown Host/SNI
- Containers renamed to nginx and certbot

hermes_docker (new role):
- Official Hermes image, host networking, gateway and dashboard bound
  to loopback and published through nginx-proxy
- Dashboard login via basic-auth provider, secrets from the vault
- Main model set from hermes_model (fantasmic endpoint, key from vault);
  config.yaml overrides are merged from a 0600 staged file that is
  removed afterwards, so the key never appears in a command line
- Data dir owner-only (HERMES_HOME_MODE=0700)

paperclip-docker:
- Remove nginx, certbot and docker-compose download tasks; deploy with
  community.docker.docker_compose_v2 (idempotent, remove_orphans)
- Stop passing the Cloudflare token and certbot email to the Paperclip
  container via env_file
- Bind Postgres to 127.0.0.1 (host networking exposed it on all
  interfaces); stop chowning pgdata, which the postgres image owns
- Handler recreates the paperclip service so .env changes apply
- Domain m-v.wdw-labs.cloud -> cranium-command.wdw-labs.cloud

Playbooks:
- Add day1_deploy_nginx_proxy.yml and day1_deploy_hermes_docker.yml
- deploy_paperclip_stack.yml: run nginx-proxy before paperclip-docker,
  CLI-based container checks (no Docker SDK dependency)
- Remove deploy-hermes.yml

Host vars: add nginx_proxy_certbot_email and nginx_proxy_certbot_staging
(staging stays on until the switch to real certs).
This commit is contained in:
rblundon committed 2026-09-30 23:12:40 -05:00
1 parent 5b48536c32
commit 881e2e24a8
37 files changed
+1683 -755

No files matched your search

@@ -2,31 +2,30 @@
# Paperclip deployment paths and configuration
paperclip_base_path: /opt/paperclip
paperclip_data_dir: "{{ paperclip_base_path }}/data"
paperclip_certs_dir: "{{ paperclip_base_path }}/certs"
paperclip_port: 3100
paperclip_domain: muppet-vision.wdw-labs.cloud
paperclip_host: muppet-vision.wdw-labs.cloud
paperclip_domain: cranium-command.wdw-labs.cloud
paperclip_host: cranium-command.wdw-labs.cloud
# paperclip_listen_host: 127.0.0.1 # Bootstrap local deployment
paperclip_listen_host: 0.0.0.0 # Switch to this host after CEO is functional on local model
# Paperclip allowed hostnames (space or comma-separated)
# Includes: domain name, localhost (for internal/local access), and client IP
paperclip_allowed_hostnames: "{{ paperclip_domain }} localhost 192.168.10.50"
# Deployment mode: authenticated (requires login) or local_trusted (no auth, trusted network)
paperclip_deployment_mode: "authenticated"
# paperclip_deployment_mode: "local_trusted" # Bootstrap local deployment
paperclip_deployment_mode: "authenticated" # Switch to this model after CEO is functional on local model
# Reverse proxy configuration
nginx_port: 443
nginx_http_port: 80
# TLS / reverse proxy are handled by the nginx-proxy role (/opt/nginx).
# Set nginx_proxy_certbot_email in host_vars; run day1_deploy_nginx_proxy.yml first.
# HTTPS/SSL configuration
certbot_email: ryan.blundon@protonmail.com
certbot_provider: cloudflare
# Inference endpoint (vLLM or remote OpenAI-compatible)
vllm_endpoint: "http://127.0.0.1:8000"
# Inference endpoints (vLLM or remote OpenAI-compatible)
illuminations_endpoint: "http://illuminations:8000"
fantasmic_endpoint: "http://fantasmic.wdw-labs.cloud:8000"
wishes_endpoint: "http://fantasmic.wdw-labs.cloud:8000"
wishes_endpoint: "http://wishes.wdw-labs.cloud:8000"
# NOTE: Authentication secrets (BETTER_AUTH_SECRET, PAPERCLIP_AGENT_JWT_SECRET)
# are auto-generated during deployment and stored in .env (mode 0600)
# No need to specify them here — they're created with: openssl rand -hex 24
# NOTE: Secrets come from the ansible vault and are written to .env (mode 0600):
# vault_better_auth_secret, vault_paperclip_agent_jwt_secret,
# vault_illuminations_api_key, vault_fantasmic_api_key
# Everything in .env is passed into the Paperclip container (env_file), so only
# put values here that Paperclip itself should be able to read.
@@ -1,19 +1,12 @@
---
# Recreate (not just restart) so .env / env_file changes are actually applied.
# Skipped when the deploy task already created or recreated containers this run.
- name: Restart paperclip stack
shell: |
cd {{ paperclip_base_path }}
/usr/local/bin/docker-compose restart
become: yes
- name: Reload nginx
shell: |
docker exec paperclip-nginx nginx -s reload
become: yes
ignore_errors: yes
- name: Reload certbot
shell: |
docker restart paperclip-certbot
become: yes
ignore_errors: yes
community.docker.docker_compose_v2:
project_src: "{{ paperclip_base_path }}"
services:
- paperclip
state: present
recreate: always
become: true
when: not (paperclip_deploy.changed | default(false))
+97 -110
View File
@@ -1,27 +1,6 @@
---
- name: Install Docker SDK for Python
apt:
name: python3-docker
state: present
become: true
- name: Download latest docker-compose from GitHub
shell: |
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
become: true
- name: Verify docker-compose installation
shell: /usr/local/bin/docker-compose --version
register: compose_version
become: true
- name: Show docker-compose version
debug:
msg: "{{ compose_version.stdout }}"
- name: Create paperclip directories
file:
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: '0755'
@@ -29,97 +8,77 @@
group: root
loop:
- "{{ paperclip_base_path }}"
- "{{ paperclip_data_dir }}"
- "{{ paperclip_certs_dir }}"
# The postgres image chowns its own data dir on start; do not chown it here.
- "{{ paperclip_base_path }}/pgdata"
become: true
tags: [paperclip, files]
- name: Create OpenCode config directory
file:
path: "{{ paperclip_base_path }}/.config/opencode"
# Container runs as UID 1000. Deliberately NOT recursive: recurse + mode would
# chmod every file the app writes under these paths on each run. A fresh
# deploy has no root-owned content to repair; if you ever need to, run a
# one-off `chown -R 1000:1000 <dir>` by hand.
- name: Create container-owned directories (UID 1000)
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: '0755'
owner: 1000
group: 1000
loop:
- "{{ paperclip_data_dir }}"
- "{{ paperclip_base_path }}/.config/opencode"
- "{{ paperclip_base_path }}/.local/share/opencode"
become: true
tags: [paperclip, files]
- name: Create OpenCode .local/share directory
file:
path: "{{ paperclip_base_path }}/.local/share/opencode"
state: directory
mode: '0755'
owner: 1000
group: 1000
become: yes
- name: Deploy OpenCode auth.json with vLLM credentials
template:
src: opencode-auth.json.j2
dest: "{{ paperclip_base_path }}/.local/share/opencode/auth.json"
mode: '0600'
owner: 1000
group: 1000
become: true
no_log: true
- name: Fix directory ownership for container user (UID 1000)
shell: |
chown -R 1000:1000 {{ paperclip_data_dir }}
chown -R 1000:1000 {{ paperclip_base_path }}/pgdata
chown -R 1000:1000 {{ paperclip_base_path }}/.config
become: true
# - name: Deploy OpenCode auth.json with vLLM credentials
# template:
# src: opencode-auth.json.j2
# dest: "{{ paperclip_base_path }}/.local/share/opencode/auth.json"
# mode: '0600'
# owner: 1000
# group: 1000
# become: true
# no_log: true
# This file is both the compose interpolation source and the container's
# env_file, so everything here is visible inside the Paperclip container.
# Infrastructure secrets (Cloudflare token, certbot email) live in
# /opt/nginx/.env instead.
- name: Write secrets and configuration to .env
copy:
ansible.builtin.copy:
content: |
# Authentication secrets (from vault)
BETTER_AUTH_SECRET={{ vault_better_auth_secret }}
PAPERCLIP_AGENT_JWT_SECRET={{ vault_paperclip_agent_jwt_secret }}
# Infrastructure configuration
CLOUDFLARE_API_TOKEN={{ vault_cloudflare_api_token }}
CERTBOT_EMAIL={{ certbot_email }}
PAPERCLIP_DOMAIN={{ paperclip_domain }}
VLLM_ENDPOINT={{ vllm_endpoint }}
VLLM_API_KEY={{ vault_vllm_api_key }}
# Inference endpoints
ILLUMINATIONS_ENDPOINT={{ illuminations_endpoint }}
ILLUMINATIONS_API_KEY={{ vault_illuminations_api_key }}
FANTASMIC_ENDPOINT={{ fantasmic_endpoint }}
FANTASMIC_API_KEY={{ vault_fantasmic_api_key }}
dest: "{{ paperclip_base_path }}/.env"
mode: '0600'
owner: root
group: root
become: true
no_log: true
notify: Restart paperclip stack
tags: [paperclip, files]
# No notify: the deploy task below recreates whatever the compose file changed.
- name: Template docker-compose.yml
template:
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ paperclip_base_path }}/docker-compose.yml"
mode: '0644'
owner: root
group: root
become: true
notify: Restart paperclip stack
- name: Template nginx config
template:
src: nginx.conf.j2
dest: "{{ paperclip_base_path }}/nginx.conf"
mode: '0644'
owner: root
group: root
become: true
notify: Reload nginx
- name: Template certbot init script
template:
src: certbot-init.sh.j2
dest: "{{ paperclip_base_path }}/certbot-init.sh"
mode: '0755'
owner: root
group: root
become: true
tags: [paperclip, files]
- name: Template OpenCode config
template:
ansible.builtin.template:
src: opencode.jsonc.j2
dest: "{{ paperclip_base_path }}/.config/opencode/opencode.jsonc"
mode: '0644'
@@ -127,43 +86,71 @@
group: 1000
become: true
notify: Restart paperclip stack
tags: [paperclip, files]
- name: Stop and remove old containers
shell: |
cd {{ paperclip_base_path }}
/usr/local/bin/docker-compose down --remove-orphans 2>/dev/null || true
# Idempotent: only changed services are recreated. remove_orphans also cleans
# up the old paperclip-nginx / paperclip-certbot containers if they still exist.
- name: Deploy paperclip stack
community.docker.docker_compose_v2:
project_src: "{{ paperclip_base_path }}"
pull: missing
remove_orphans: true
state: present
register: paperclip_deploy
become: true
ignore_errors: true
- name: Deploy docker compose stack
shell: |
cd {{ paperclip_base_path }}
/usr/local/bin/docker-compose up -d
become: true
register: compose_result
tags: [paperclip, deploy]
- name: Wait for Paperclip to be ready
uri:
ansible.builtin.uri:
url: "http://localhost:{{ paperclip_port }}/health"
status_code: 200
register: result
until: result.status == 200
register: paperclip_health
until: paperclip_health.status == 200
retries: 30
delay: 5
ignore_errors: true
tags: [paperclip, deploy, verify]
- name: Wait for nginx to be ready
uri:
url: "https://{{ paperclip_domain }}/"
validate_certs: no
status_code: [200, 301, 302]
register: result
until: result.status in [200, 301, 302]
retries: 30
- name: Warn if Paperclip never became healthy
ansible.builtin.debug:
msg: "WARNING: Paperclip did not report healthy on port {{ paperclip_port }}. Check: docker logs paperclip"
when: paperclip_health.status | default(0) != 200
tags: [paperclip, verify]
# Requires day1_deploy_nginx_proxy.yml to have run first.
- name: Publish Paperclip through nginx-proxy
ansible.builtin.include_role:
name: nginx-proxy
tasks_from: add_vhost
apply:
become: true
tags: [paperclip, nginx]
vars:
vhost_name: paperclip
vhost_domain: "{{ paperclip_domain }}"
vhost_upstream_port: "{{ paperclip_port }}"
vhost_health_path: /health
tags: [paperclip, nginx]
# --resolve keeps this independent of DNS: it tests nginx, the cert and the
# upstream. DNS is a separate (manual) prerequisite.
- name: Verify HTTPS through nginx
ansible.builtin.command: >-
curl -sk -o /dev/null -w '%{http_code}'
--resolve {{ paperclip_domain }}:443:127.0.0.1
https://{{ paperclip_domain }}/
register: paperclip_https
until: paperclip_https.stdout in ['200', '301', '302']
retries: 6
delay: 5
ignore_errors: true
changed_when: false
failed_when: false
tags: [paperclip, verify]
- name: Verify Paperclip is accessible via https
debug:
msg: "Paperclip is accessible at https://{{ paperclip_domain }}"
when: result.status is defined and result.status in [200, 301, 302]
- name: Report HTTPS result
ansible.builtin.debug:
msg: >-
{{ 'Paperclip is serving at https://' ~ paperclip_domain
if paperclip_https.stdout in ['200', '301', '302']
else 'WARNING: https://' ~ paperclip_domain ~ ' returned ' ~ (paperclip_https.stdout | default('no response')) ~ ' (502 usually means Paperclip is not up yet)' }}
tags: [paperclip, verify]
@@ -1,65 +0,0 @@
#!/bin/sh
set -e
DOMAIN="${PAPERCLIP_DOMAIN}"
CERT_PATH="/etc/letsencrypt/live/${DOMAIN}/fullchain.pem"
EMAIL="${CERTBOT_EMAIL}"
TOKEN="${CLOUDFLARE_API_TOKEN}"
# Verify environment variables
if [ -z "$DOMAIN" ]; then
echo "ERROR: PAPERCLIP_DOMAIN not set"
exit 1
fi
if [ -z "$EMAIL" ]; then
echo "ERROR: CERTBOT_EMAIL not set"
exit 1
fi
if [ -z "$TOKEN" ]; then
echo "ERROR: CLOUDFLARE_API_TOKEN not set"
exit 1
fi
# Check if certificate already exists
if [ -f "$CERT_PATH" ]; then
echo "Certificate for $DOMAIN already exists at $CERT_PATH"
exit 0
fi
echo "Requesting certificate for $DOMAIN via Cloudflare DNS challenge..."
# Create a temporary credentials file (more reliable than env var with certbot)
CREDS_FILE="/tmp/cloudflare.ini"
cat > "$CREDS_FILE" << EOF
dns_cloudflare_api_token = ${TOKEN}
EOF
chmod 600 "$CREDS_FILE"
# Request certificate with Cloudflare DNS plugin
if certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials "$CREDS_FILE" \
--agree-tos \
--no-eff-email \
--email "$EMAIL" \
--non-interactive \
-d "$DOMAIN"; then
echo "Certificate obtained successfully for $DOMAIN"
else
echo "ERROR: Failed to obtain certificate for $DOMAIN"
rm -f "$CREDS_FILE"
exit 1
fi
# Clean up credentials file
rm -f "$CREDS_FILE"
# Verify certificate
if [ -f "$CERT_PATH" ]; then
echo "Certificate verified at $CERT_PATH"
else
echo "ERROR: Certificate not found at $CERT_PATH after successful certbot run"
exit 1
fi
@@ -3,6 +3,8 @@ services:
image: postgres:16-alpine
container_name: paperclip-db
network_mode: host
# network_mode: host would otherwise expose Postgres on every interface
command: ["postgres", "-c", "listen_addresses=127.0.0.1"]
environment:
POSTGRES_USER: paperclip
POSTGRES_PASSWORD: paperclip
@@ -23,12 +25,12 @@ services:
env_file:
- {{ paperclip_base_path }}/.env
environment:
HOST: "127.0.0.1"
HOST: {{ paperclip_listen_host }}
PAPERCLIP_HOME: /paperclip
PAPERCLIP_PORT: {{ paperclip_port }}
PAPERCLIP_PUBLIC_URL: "https://{{ paperclip_domain }}"
PAPERCLIP_DEPLOYMENT_MODE: "{{ paperclip_deployment_mode }}"
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
# PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
PAPERCLIP_ALLOWED_HOSTNAMES: "{{ paperclip_allowed_hostnames }}"
DATABASE_URL: "postgresql://paperclip:paperclip@localhost:5432/paperclip"
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
@@ -50,43 +52,3 @@ services:
timeout: 10s
retries: 3
start_period: 60s
certbot:
image: certbot/dns-cloudflare:latest
container_name: paperclip-certbot
volumes:
- {{ paperclip_certs_dir }}:/etc/letsencrypt
- {{ paperclip_base_path }}/certbot-init.sh:/certbot-init.sh:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
CERTBOT_EMAIL: ${CERTBOT_EMAIL}
PAPERCLIP_DOMAIN: ${PAPERCLIP_DOMAIN}
entrypoint: /bin/sh
command:
- -c
- |
/certbot-init.sh && while true; do certbot renew --quiet; sleep 12h; done
restart: unless-stopped
nginx:
image: nginx:latest
container_name: paperclip-nginx
network_mode: host
volumes:
- {{ paperclip_base_path }}/nginx.conf:/etc/nginx/nginx.conf:ro
- {{ paperclip_certs_dir }}:/etc/nginx/certs:ro
depends_on:
- certbot
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
paperclip_data:
driver: local
@@ -1,86 +0,0 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
# Redirect HTTP to HTTPS
server {
listen 80;
server_name {{ paperclip_domain }};
return 301 https://$server_name$request_uri;
}
# HTTPS server
server {
listen 443 ssl http2;
server_name {{ paperclip_domain }};
# SSL certificates from certbot
ssl_certificate /etc/nginx/certs/live/{{ paperclip_domain }}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/{{ paperclip_domain }}/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
proxy_pass http://127.0.0.1:{{ paperclip_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Port $server_port;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering
proxy_buffering off;
proxy_request_buffering off;
}
# Health check endpoint (no auth)
location /health {
proxy_pass http://127.0.0.1:{{ paperclip_port }}/health;
access_log off;
}
}
}
@@ -2,7 +2,7 @@
"vllm": {
"type": "api",
"key": "{{ vault_vllm_api_key }}"
}
},
"fantasmic": {
"type": "api",
"key": "{{ vault_fantasmic_api_key }}"
@@ -1,9 +1,9 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"vllm": {
"illuminations": {
"npm": "@ai-sdk/openai-compatible",
"name": "vLLM (Local)",
"name": "illuminations",
"models": {
"gemma-4-26b": {
"id": "Gemma-4-26B-A4B-it-AWQ",
@@ -11,9 +11,9 @@
}
},
"options": {
"baseURL": "{{ vllm_endpoint }}/v1",
"baseURL": "{{ illuminations_endpoint }}/v1",
"headers": {
"Authorization": "Bearer {{ vault_vllm_api_key }}"
"Authorization": "Bearer {env:ILLUMINATIONS_API_KEY}"
}
}
}