From f701ad823f77db47dfa1c5fb72e4661d782e50b0 Mon Sep 17 00:00:00 2001 From: Ryan Blundon Date: Sun, 27 Sep 2026 16:47:04 -0500 Subject: [PATCH] feat: Add paperclip-docker Ansible role for RTX 3090 Deploy Paperclip AI with PostgreSQL, nginx reverse proxy, and Cloudflare SSL/TLS on illuminations.wdw-labs.cloud. Configured for local_trusted mode (LAN-only, no auth) with host networking and auto-generated secrets. Stack: PostgreSQL 16 + Paperclip + nginx + certbot Hostname: muppet-vision.wdw-labs.cloud vLLM endpoint: localhost:8000 (DeepSeek-R1-32B Q4) --- .../illuminations/host_vars_illuminations.yml | 20 ++ ansible/host_vars/illuminations/vars.yml | 240 ++++++++++++++++++ ansible/inventory.yml | 4 +- ansible/playbooks/deploy_paperclip.yml | 122 --------- ansible/playbooks/deploy_paperclip_stack.yml | 72 ++++++ .../roles/paperclip-docker/INSTALLATION.md | 191 ++++++++++++++ ansible/roles/paperclip-docker/README.md | 136 ++++++++++ .../roles/paperclip-docker/defaults/main.yml | 30 +++ .../roles/paperclip-docker/handlers/main.yml | 19 ++ ansible/roles/paperclip-docker/tasks/main.yml | 142 +++++++++++ .../templates/certbot-init.sh.j2 | 65 +++++ .../templates/docker-compose.yml.j2 | 90 +++++++ .../paperclip-docker/templates/nginx.conf.j2 | 86 +++++++ 13 files changed, 1093 insertions(+), 124 deletions(-) create mode 100644 ansible/host_vars/illuminations/host_vars_illuminations.yml create mode 100644 ansible/host_vars/illuminations/vars.yml delete mode 100644 ansible/playbooks/deploy_paperclip.yml create mode 100644 ansible/playbooks/deploy_paperclip_stack.yml create mode 100644 ansible/roles/paperclip-docker/INSTALLATION.md create mode 100644 ansible/roles/paperclip-docker/README.md create mode 100644 ansible/roles/paperclip-docker/defaults/main.yml create mode 100644 ansible/roles/paperclip-docker/handlers/main.yml create mode 100644 ansible/roles/paperclip-docker/tasks/main.yml create mode 100644 ansible/roles/paperclip-docker/templates/certbot-init.sh.j2 create mode 100644 ansible/roles/paperclip-docker/templates/docker-compose.yml.j2 create mode 100644 ansible/roles/paperclip-docker/templates/nginx.conf.j2 diff --git a/ansible/host_vars/illuminations/host_vars_illuminations.yml b/ansible/host_vars/illuminations/host_vars_illuminations.yml new file mode 100644 index 0000000..e08f54b --- /dev/null +++ b/ansible/host_vars/illuminations/host_vars_illuminations.yml @@ -0,0 +1,20 @@ +--- +# Paperclip Docker deployment +paperclip_domain: muppet-vision.wdw-labs.cloud +paperclip_base_path: /opt/paperclip +paperclip_port: 3100 +certbot_email: ryan.blundon@protonmail.com + +# vLLM inference endpoint (local) +vllm_endpoint: "http://127.0.0.1:8000" + +# # DNS entries (if using your existing dns-manager role) +# dns_entries: +# - name: illuminations +# domain: wdw-labs.cloud +# type: A +# value: "{{ ansible_host }}" +# - name: muppet-vision +# domain: wdw-labs.cloud +# type: A +# value: "{{ ansible_host }}" diff --git a/ansible/host_vars/illuminations/vars.yml b/ansible/host_vars/illuminations/vars.yml new file mode 100644 index 0000000..b77e425 --- /dev/null +++ b/ansible/host_vars/illuminations/vars.yml @@ -0,0 +1,240 @@ +--- +# ------------------------------------------------------------------------------ +# FILE: ansible/host_vars/astro_orbiter/vars.yml +# HOST: astro-orbiter (10.1.71.130) +# ROLE: llama.cpp LLM inference host — Ryzen 7 5800XT / RTX 3090 (ATX rebuild, +# 2026-08-04). Superseded the prior AMD RX 5700 / Ollama config below; +# drive was transplanted into new hardware, not reinstalled. +# ------------------------------------------------------------------------------ + +ansible_host: 10.1.71.130 +ansible_user: jarvis +ansible_ssh_private_key_file: ~/.ssh/id_jarvis +ansible_become: true + +# LVM root expansion — xlarge template uses sda3 partition, standard VG/LV names +common_expand_root_lvm: true +common_root_pv: /dev/sda3 +common_root_vg: ubuntu-vg +common_root_lv: ubuntu-lv + +# --- Staged GGUF models for the llama.cpp router (:8002) --------------------- +# Data-driven list consumed by roles/llm-inference-multimodel tasks/models.yml +# (loop -> tasks/stage_model.yml). Each entry is idempotently staged into +# /opt/models: stat + EXACT-size check vs HF manifest; skip (no download, no +# restart) when present + size matches. Source repos are public bartowski GGUFs +# on HuggingFace (no auth). A router restart is notified ONLY when a new GGUF +# is actually downloaded. +# Added 2026-08-12 (War Machine): codify Phi-3.5-mini-instruct-Q8_0 and +# Meta-Llama-3.1-8B-Instruct-Q4_K_M as router models alongside the production +# Qwen3.6-35B-A3B-UD-Q4_K_S. The live files were already present/correct on +# astro-orbiter; this pass codifies them. Future adds = append to this list. +# Router --models-max override for astro-orbiter. +# Default in defaults/main.yml is 1 (conservative). Bumped to 4 on 2026-08-12 +# (t_33acbb2e) so the router can keep more than one GGUF resident on-demand +# and LRU-evict when needed. +# +# VRAM NOTE (t_33acbb2e, updated t_55c164f5, updated t_34b96e83, updated t_f5f7e9ad, updated t_441470b9, updated t_c5cef2b2): +# With models-max=4 and all 6 GGUFs registered, worst case is all 6 loaded simultaneously: +# Qwen3.8-27B Q4_K_M: ~20.0GB (weights ~17.1GB + KV ~2.9GB @ 65536 ctx, q4_0) ← CORRECTED (ctx rolled back from 128K to 65536, t_c9fed26c 2026-08-18) +# Phi-3.5-mini-instruct Q8_0: ~4.3GB (weights ~3.8GB + KV ~0.5GB @ 32K ctx) +# Meta-Llama-3.1-8B Q4_K_M: ~5.6GB (weights ~4.6GB + KV ~0.2GB @ 8K ctx) +# Qwen2.5-Coder-14B Q4_K_M: ~9.0GB (weights ~8.4GB + KV ~0.6GB @ 16K ctx) +# nomic-embed-text-v1.5 Q4_K_M: ~0.09GB (~84MB, embedding only — no KV cache) +# Qwen3-8B Q4_K_M: ~5.5GB (weights ~4.68GB + KV ~0.5GB @ 32K ctx, q4_0) +# Total worst-case: ~44.5GB >> 24GB RTX 3090 +# +# OOM RISK: Full co-residency is impossible on 24GB. LRU eviction prevents this +# in practice: models-max=4 means the router can REGISTER 6 models but only keeps +# up to 4 LOADED simultaneously — the router will evict the LRU model when a new +# one is needed. nomic-embed-text-v1.5 is pinned via sleep-idle-seconds=-1 and +# load-on-startup=true but it uses only ~84MB, so it never meaningfully changes +# the budget. In single-user homelab operation, only one generative model is active +# at a time alongside the always-resident embedding model. +# Qwen3.8-27B alone uses ~17,804 MiB (weights+KV @ 65536 ctx); co-residency +# with Coder (~9GB) = ~27GB > 24GB. LRU eviction handles this automatically. +# Ryan should be aware this means model-switching always incurs a ~30-60s +# cold-load latency when switching between Qwen3.8-27B and any other model. +# Proceeding to models-max=4 as instructed; flagged for Ryan's attention. +# Router --models-max override for astro-orbiter. +# UPDATED (t_f5f7e9ad, 2026-08-16): Set to 2 because Qwen3.8-27B-Q4_K_M +# uses 17,804 MiB at 65536 ctx. Only nomic-embed (558MB, pinned) and ONE +# generative model can be resident simultaneously. Co-residency of Qwen3.8 +# with any auxiliary model (Phi 8.3GB, Llama 5.9GB, Coder 9GB) exceeds 24GB. +# models-max=2: slot 1 = nomic-embed (pinned, always loaded), slot 2 = LRU +# generative model (Qwen3.8 primary, cold-loaded on first request ~30-60s; +# auxiliary models evict it on demand, and vice versa). +# NOTE: Qwen3.8 does NOT have load-on-startup — it loads on first request. +# This avoids an LRU eviction race with nomic-embed at startup. +# UPDATED (t_72646029, 2026-08-17): CPU offload for Coder + Llama changes the +# constraint. Coder and Llama now use CPU inference (n-gpu-layers=0). GPU-resident +# VRAM: Qwen3.8 (~17,804 MiB at 65536 ctx) + nomic-embed (558 MiB, pinned) plus +# the CUDA-context buffers llama.cpp 6ea215d allocates for the CPU models (~1.4-1.7GB +# each) = ~20,004 MiB steady-state, below the 24,576 MiB physical limit. +# CORRECTED (t_c5cef2b2, 2026-08-19): ctx-size was rolled back from 131072 to 65536 +# (t_c9fed26c 2026-08-18). Qwen3.8 VRAM at 65536: 17,804 MiB (not 20,302 MiB). +# models-max raised to 4: nomic (slot 1, pinned) + Qwen3.8 (slot 2, GPU) + +# Llama (slot 3, CPU) + Coder (slot 4, CPU). Phi (GPU, ~8.3GB) and new +# Qwen3-8B (GPU, ~5.5GB) can also be requested but evict Qwen3.8 due to VRAM. +# models-max=4 is required so CPU-offloaded models count as loaded without +# evicting Qwen3.8. +llm_router_models_max: 4 + +llm_staged_models: + - filename: "Phi-3.5-mini-instruct-Q8_0.gguf" + url: "https://huggingface.co/bartowski/Phi-3.5-mini-instruct-GGUF/resolve/main/Phi-3.5-mini-instruct-Q8_0.gguf" + size_bytes: 4061222688 + source_repo: "bartowski/Phi-3.5-mini-instruct-GGUF" + - filename: "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf" + url: "https://huggingface.co/bartowski/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf" + size_bytes: 4920739232 + source_repo: "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF" + - filename: "Qwen2.5-Coder-14B-Instruct-Q4_K_M.gguf" + url: "https://huggingface.co/bartowski/Qwen2.5-Coder-14B-Instruct-GGUF/resolve/main/Qwen2.5-Coder-14B-Instruct-Q4_K_M.gguf" + size_bytes: 8988111072 + source_repo: "bartowski/Qwen2.5-Coder-14B-Instruct-GGUF" + - filename: "nomic-embed-text-v1.5-Q4_K_M.gguf" + url: "https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf" + size_bytes: 84106624 + source_repo: "nomic-ai/nomic-embed-text-v1.5-GGUF" + # Added t_c5cef2b2 (2026-08-19, War Machine): Qwen3-8B dense 8B model for + # aux tasks (routing, rewriting, structured extraction, tool-call construction). + # Source: bartowski/Qwen_Qwen3-8B-GGUF (public, no auth). HF filename is + # Qwen_Qwen3-8B-Q4_K_M.gguf; stored locally as Qwen3-8B-Q4_K_M.gguf. + # Exact size verified from HF manifest (content-length): 5,027,784,224 bytes. + # VRAM: ~4.68GB weights + ~0.5GB KV @ 32K ctx (q4_0) ≈ 5.2GB total. + # Thinking mode ON by default; use /no_think for latency-sensitive aux tasks. + - filename: "Qwen3-8B-Q4_K_M.gguf" + url: "https://huggingface.co/bartowski/Qwen_Qwen3-8B-GGUF/resolve/main/Qwen_Qwen3-8B-Q4_K_M.gguf" + size_bytes: 5027784224 + source_repo: "bartowski/Qwen_Qwen3-8B-GGUF" + +# --- deploy-vllm role: vllm_models override (t_r1d32b_swap, 2026-09-01) ----- +# Ansible's hash_behaviour is "replace" (see ansible.cfg) — a host_vars list +# variable REPLACES the role default list wholesale, it does not deep-merge. +# +# SWAP (Ryan direction, 2026-09-01): Qwen2.5-32B-Instruct-AWQ retired, +# replaced with DeepSeek-R1-Distill-Qwen-32B-AWQ, max_model_len=32768. +# "Single model only" — nomic-embed-text-v1.5 (embedding, :8020) and +# Qwen3-8B-AWQ (aux, :8010, already disabled) are BOTH disabled here. +# DeepSeek gets the full 24GB card to itself. Nothing in production +# consumed nomic-embed at the time of this swap (Hindsight uses its own +# bundled 384-dim embedder; OpenViking pointed at the old llama-swap +# endpoint, already stopped) — confirmed with Ryan before disabling. +# +# Model choice: casperhansen/deepseek-r1-distill-qwen-32b-awq — same +# quantizer/toolchain (AutoAWQ) as the outgoing Qwen2.5-32B-Instruct-AWQ, +# widely used, 4-bit GEMM AWQ, ~19.3GB on disk (4 safetensors shards). +# Architecture: Qwen2ForCausalLM (DeepSeek-R1 distilled onto Qwen2.5-32B +# base) — same vLLM code path as the outgoing model, no new serving +# support needed. Native max_position_embeddings=131072; we cap at 32768 +# per the task's explicit max-model-len requirement. +# +# VRAM math: ~19.3GB weights (4-bit AWQ) + KV cache at 32768 ctx (GQA, +# 8 KV heads, 128 head_dim, 64 layers, fp16 KV by default) ≈ 19.3GB + +# ~4GB KV+overhead ≈ 23.3GB — tight but the FULL 24GB card is now +# available (no co-resident nomic-embed/Qwen3-8B taking a share, unlike +# the outgoing Qwen2.5-32B config). gpu_memory_utilization=0.95 (role +# default) + enforce_eager retained as the proven-stable mitigation from +# t_e6facb19/t_ca1af9fb (avoids CUDA graph capture VRAM spike; this host's +# only validated way to avoid crash-loop-to-stabilize behavior on this +# card). If 0.95 OOMs at 32768 ctx once tested live, drop to 0.90 next +# (documented fallback, same pattern as the outgoing model). +# +# DeepSeek-R1 output note: reasoning traces stream in tags before +# the final answer — this is expected R1-distill behavior, not a bug. +# Model card recommends temperature 0.5-0.7 (not 0, not vLLM's greedy +# default) to avoid repetition/incoherence; not set here (server-side +# default), left to be set client-side per the model card's guidance — +# flagging for whoever wires this into Hermes profile configs next. +vllm_models: + - id: "Gemma-4-26B-A4B-it-AWQ" + hf_repo: "cyankiwi/gemma-4-26B-A4B-it-AWQ-4bit" + role: primary + # NO quantization field set (unlike the AutoAWQ-quantized DeepSeek/ + # Qwen2.5 models above) — live test (2026-09-01) found this repo's + # config.json declares quant_method: "compressed-tensors" (llm-compressor + # tool output, not classic AutoAWQ), even though the repo name says + # "AWQ-4bit". Passing --quantization awq explicitly caused a hard + # pydantic ValidationError at every single startup attempt: "Quantization + # method specified in the model config (compressed-tensors) does not + # match the quantization method specified in the `quantization` argument + # (awq)." vLLM auto-detects the quant method correctly from the model's + # own config.json when --quantization is omitted — confirmed fix, clean + # start. Lesson: don't trust a HF repo's naming convention ("...-AWQ...") + # for the `quantization:` field here — check config.json's quant_method. + port: 8000 + # Ryan direction (2026-09-01, t_gemma4_swap): DeepSeek-R1-Distill-Qwen-32B + # retired after confirming its `auto` tool-choice reliability is a known, + # documented DeepSeek-R1-distillation limitation (trained on pure + # reasoning traces, no function-calling data — GitHub-confirmed upstream, + # not a vLLM config gap). Replaced with Gemma 4 26B A4B (Google, + # Apache 2.0, US-origin — matches Ryan's standing model-origin + # preference, unlike Qwen/DeepSeek). Chose MoE (26B A4B, 3.8B active) + # over the dense 31B variant: ~3.7GB smaller on-disk AWQ footprint + # (17.2GB vs 20.9GB) buys more KV-cache headroom on this tight 24GB + # card, and decode should be faster (memory-bandwidth-bound on active + # params, not total params). Tradeoff accepted: MoE scores lower than + # dense on the Tau2 tool-use benchmark (68.2% vs 76.9%) but still beats + # every other size in the family except the 31B on most reasoning + # benchmarks. Model choice: cyankiwi/gemma-4-26B-A4B-it-AWQ-4bit — + # AutoAWQ 4-bit group_size=32, MoE expert layers (gate/up/down/router) + # explicitly excluded from quantization ("ignore" list in config.json) + # per standard llm-compressor MoE quant practice — only the dense + # attention/projection layers are 4-bit, experts stay higher precision. + # Native architecture: Gemma4ForConditionalGeneration (registered + # natively in this host's installed vLLM 0.28.0 — vllm/model_executor/ + # models/registry.py line 415 — no plugin/trust-remote-code needed). + # Native max_position_embeddings: 262144 (256K) — Hermes's 64K floor is + # comfortably covered without any context-extension trick. + max_model_len: 65536 + # VRAM math (not yet live-validated — see swap validation log below + # once run): AWQ weights ~17.2GB on disk (dense attn 4-bit + MoE + # experts higher-precision, per config.json's compressed-tensors + # ignore list). Starting the KV cache dtype at int4_per_token_head + # from the outset (rather than fp16 -> fp8 -> int4 trial-and-error like + # the DeepSeek swap) since that same escalation pattern is expected to + # repeat on this VRAM-constrained card for any 20+ GB model at >32K ctx. + kv_cache_dtype: int4_per_token_head + gpu_memory_utilization: 0.95 + enforce_eager: true + # Native tool-calling + reasoning support (no `hermes` workaround + # needed, unlike DeepSeek-R1-Distill): Gemma4EngineToolParser and + # Gemma4ParserReasoningAdapter are both registered natively in this + # host's vLLM 0.28.0 (vllm/tool_parsers/__init__.py, + # vllm/reasoning/__init__.py) — purpose-built for this model's actual + # output format, not a same-family approximation. + enable_auto_tool_choice: true + tool_call_parser: gemma4 + reasoning_parser: gemma4 + enabled: true + - id: "Qwen3-8B-AWQ" + hf_repo: "Qwen/Qwen3-8B-AWQ" + role: aux + quantization: awq + port: 8010 + max_model_len: 32768 + gpu_memory_utilization: 0.15 + enforce_eager: true + enabled: false # single-model deployment — see swap note above + - id: "nomic-embed-text-v1.5" + hf_repo: "nomic-ai/nomic-embed-text-v1.5" + role: embedding + quantization: none + port: 8020 + max_model_len: 2048 + gpu_memory_utilization: 0.05 + trust_remote_code: true + enabled: false # single-model deployment — see swap note above + +# --- deploy-vllm role: boot persistence (unchanged) ------------------------- +# Still permanent/boot-persistent — same policy as the outgoing Qwen2.5-32B +# deployment (t_5508360a), just now serving one model instead of two. +vllm_service_enabled: true +vllm_service_state: started + +# Paperclip Docker deployment +paperclip_domain: muppet-vision.wdw-labs.cloud +paperclip_base_path: /opt/paperclip +paperclip_port: 3100 +certbot_email: ryan.blundon@protonmail.com diff --git a/ansible/inventory.yml b/ansible/inventory.yml index 60a537c..95677a9 100755 --- a/ansible/inventory.yml +++ b/ansible/inventory.yml @@ -80,8 +80,8 @@ honcho_server: paperclip_server: hosts: - muppet-vision: - ansible_host: 10.1.71.136 + illuminations: + ansible_host: 10.1.71.130 ansible_user: wed ansible_become: true diff --git a/ansible/playbooks/deploy_paperclip.yml b/ansible/playbooks/deploy_paperclip.yml deleted file mode 100644 index c138412..0000000 --- a/ansible/playbooks/deploy_paperclip.yml +++ /dev/null @@ -1,122 +0,0 @@ ---- -- name: Deploy Paperclip AI via Podman Quadlets - hosts: paperclip_server - become: false - vars: - deploy_dir: "/home/{{ ansible_user }}/paperclip-deploy" - quadlet_dir: "/home/{{ ansible_user }}/.config/containers/systemd" - db_user: "paperclip" - db_password: "paperclip_password_change_me" - db_name: "paperclip" - better_auth_secret: "super_secret_better_auth_key_change_me" - paperclip_public_url: "http://muppet-vision.local:3100" - - tasks: - - name: "Ensure deployment and quadlet directories exist" - file: - path: "{{ item }}" - state: directory - mode: '0755' - loop: - - "{{ deploy_dir }}" - - "{{ quadlet_dir }}" - - - name: "Clone Paperclip repository" - git: - repo: 'https://github.com/paperclipai/paperclip.git' - dest: "{{ deploy_dir }}/source" - version: master - force: yes - - - name: "Build paperclip-local image" - shell: | - podman build -t paperclip-local {{ deploy_dir }}/source - args: - chdir: "{{ deploy_dir }}" - - - name: "Create environment file (paperclip.env)" - copy: - dest: "{{ quadlet_dir }}/paperclip.env" - content: | - POSTGRES_USER={{ db_user }} - POSTGRES_PASSWORD={{ db_password }} - POSTGRES_DB={{ db_name }} - BETTER_AUTH_SECRET={{ better_auth_secret }} - PAPERCLIP_PUBLIC_URL={{ paperclip_public_url }} - mode: '0600' - - - name: "Deploy Quadlet: paperclip.pod" - copy: - dest: "{{ quadlet_dir }}/paperclip.pod" - content: | - [Unit] - Description=Paperclip Pod - - [Pod] - PodName=paperclip - PublishPort=3100:3100 - mode: '0644' - - - name: "Deploy Quadlet: paperclip-db.container" - copy: - dest: "{{ quadlet_dir }}/paperclip-db.container" - content: | - [Unit] - Description=PostgreSQL for Paperclip - - [Container] - Image=docker.io/library/postgres:17-alpine - ContainerName=paperclip-db - Pod=paperclip.pod - Volume=paperclip-pgdata:/var/lib/postgresql/data - EnvironmentFile=%h/.config/containers/systemd/paperclip.env - HealthCmd=pg_isready -U $POSTGRES_USER -d $POSTGRES_DB -h localhost || exit 1 - HealthInterval=15s - HealthTimeout=5s - HealthRetries=5 - - [Service] - Restart=on-failure - TimeoutStartSec=60 - - [Install] - WantedBy=default.target - mode: '0644' - - - name: "Deploy Quadlet: paperclip.container" - copy: - dest: "{{ quadlet_dir }}/paperclip.container" - content: | - [Unit] - Description=Paperclip AI Agent Orchestrator - Requires=paperclip-db.service - After=paperclip-db.service - - [Container] - Image=paperclip-local - ContainerName=paperclip - Pod=paperclip.pod - Volume=%h/.local/share/paperclip:/paperclip:Z - Environment=HOST=0.0.0.0 - Environment=PAPERCLIP_HOME=/paperclip - Environment=PAPERCLIP_DEPLOYMENT_MODE=authenticated - Environment=PAPERCLIP_DEPLOYMENT_EXPOSURE=private - Environment=PAPERCLIP_PUBLIC_URL={{ paperclip_public_url }} - EnvironmentFile=%h/.config/containers/systemd/paperclip.env - - [Service] - Restart=on-failure - TimeoutStartSec=120 - - [Install] - WantedBy=default.target - mode: '0644' - - - name: "Reload systemd daemon" - shell: systemctl --user daemon-reload - - - name: "Start Paperclip services" - systemd: - name: paperclip.service - state: started - scope: user diff --git a/ansible/playbooks/deploy_paperclip_stack.yml b/ansible/playbooks/deploy_paperclip_stack.yml new file mode 100644 index 0000000..c40dbeb --- /dev/null +++ b/ansible/playbooks/deploy_paperclip_stack.yml @@ -0,0 +1,72 @@ +--- +- name: Deploy Paperclip stack with nginx and certbot + hosts: illuminations + become: true + vars_files: + - "{{ playbook_dir }}/../group_vars/all/vars" + - "{{ playbook_dir }}/../group_vars/all/vault" + + roles: + - docker-host + - paperclip-docker + + post_tasks: + - name: Verify Paperclip stack is running + community.docker.docker_container_info: + name: "{{ item }}" + register: container_status + loop: + - paperclip + - paperclip-nginx + - paperclip-certbot + failed_when: container_status.containers | length == 0 + changed_when: false + + - name: Verify Docker is available + command: docker --version + register: docker_version + changed_when: false + + - name: Show Docker version + debug: + msg: "Docker version: {{ docker_version.stdout }}" + + - name: Verify docker-compose plugin is available + command: docker compose version + register: compose_version + changed_when: false + ignore_errors: true + + - name: Show docker-compose version + debug: + msg: "Docker Compose version: {{ compose_version.stdout | default('docker-compose plugin not found') }}" + + - name: Display deployment summary + debug: + msg: | + Paperclip stack deployed successfully! + + Access points: + - HTTPS: https://{{ paperclip_domain }} + - HTTP (redirects to HTTPS): http://{{ paperclip_domain }} + + Services: + - Paperclip: {{ paperclip_host }}:{{ paperclip_port }} + - nginx (reverse proxy): 0.0.0.0:443 / 0.0.0.0:80 + - certbot: Running certificate renewal checks + + Data location: + - {{ paperclip_base_path }}/data + + Certificates: + - {{ paperclip_certs_dir }}/live/{{ paperclip_domain }}/ + + vLLM endpoint: + - {{ vllm_endpoint }} + + Next steps: + 1. Verify DNS resolution for {{ paperclip_domain }} + 2. Visit https://{{ paperclip_domain }} to access Paperclip + 3. Configure Paperclip with OpenCode adapter (should auto-detect vLLM at {{ vllm_endpoint }}) + 4. Set up Hermes Gateway on Mac Studio + 5. Configure agents and skills diff --git a/ansible/roles/paperclip-docker/INSTALLATION.md b/ansible/roles/paperclip-docker/INSTALLATION.md new file mode 100644 index 0000000..757ef79 --- /dev/null +++ b/ansible/roles/paperclip-docker/INSTALLATION.md @@ -0,0 +1,191 @@ +# Installation Guide - Paperclip Docker Role + +## File Placement + +Copy the files from this directory to your ansible repo as follows: + +### Role Files + +Create the role directory structure: + +```bash +mkdir -p roles/paperclip-docker/{defaults,handlers,tasks,templates} +``` + +Then copy: + +| From | To | +|------|-----| +| `defaults_main.yml` | `roles/paperclip-docker/defaults/main.yml` | +| `handlers_main.yml` | `roles/paperclip-docker/handlers/main.yml` | +| `tasks_main.yml` | `roles/paperclip-docker/tasks/main.yml` | +| `docker-compose.yml.j2` | `roles/paperclip-docker/templates/docker-compose.yml.j2` | +| `nginx.conf.j2` | `roles/paperclip-docker/templates/nginx.conf.j2` | +| `certbot-init.sh.j2` | `roles/paperclip-docker/templates/certbot-init.sh.j2` | +| `README.md` | `roles/paperclip-docker/README.md` | + +### Host Variables + +Create the host directory: + +```bash +mkdir -p host_vars/illuminations.wdw-labs.cloud +``` + +Then copy: + +| From | To | +|------|-----| +| `host_vars_illuminations.yml` | `host_vars/illuminations.wdw-labs.cloud/vars.yml` | + +### Playbook + +Copy: + +| From | To | +|------|-----| +| `deploy_paperclip_stack.yml` | `playbooks/deploy_paperclip_stack.yml` | + +## Complete Installation Command + +```bash +# From your ansible repo root + +# Create directories +mkdir -p roles/paperclip-docker/{defaults,handlers,tasks,templates} +mkdir -p host_vars/illuminations.wdw-labs.cloud + +# Copy files (adjust path to where you downloaded them) +cp /path/to/defaults_main.yml roles/paperclip-docker/defaults/main.yml +cp /path/to/handlers_main.yml roles/paperclip-docker/handlers/main.yml +cp /path/to/tasks_main.yml roles/paperclip-docker/tasks/main.yml +cp /path/to/docker-compose.yml.j2 roles/paperclip-docker/templates/ +cp /path/to/nginx.conf.j2 roles/paperclip-docker/templates/ +cp /path/to/certbot-init.sh.j2 roles/paperclip-docker/templates/ +cp /path/to/README.md roles/paperclip-docker/ +cp /path/to/host_vars_illuminations.yml host_vars/illuminations.wdw-labs.cloud/vars.yml +cp /path/to/deploy_paperclip_stack.yml playbooks/ + +# Verify structure +tree roles/paperclip-docker +tree host_vars/illuminations.wdw-labs.cloud +ls -la playbooks/deploy_paperclip_stack.yml +``` + +## Final Directory Structure + +After installation, your ansible repo should have: + +``` +. +├── roles/ +│ └── paperclip-docker/ +│ ├── README.md +│ ├── defaults/ +│ │ └── main.yml +│ ├── handlers/ +│ │ └── main.yml +│ ├── tasks/ +│ │ └── main.yml +│ └── templates/ +│ ├── certbot-init.sh.j2 +│ ├── docker-compose.yml.j2 +│ └── nginx.conf.j2 +├── playbooks/ +│ ├── deploy_paperclip_stack.yml +│ └── (other existing playbooks) +├── host_vars/ +│ ├── illuminations.wdw-labs.cloud/ +│ │ └── vars.yml +│ └── (other existing host vars) +├── group_vars/ +│ └── (existing group vars) +├── inventory.yml +├── ansible.cfg +└── (other existing files) +``` + +## Next Steps + +1. **Copy the files** as described above +2. **Review variables** in `host_vars/illuminations.wdw-labs.cloud/vars.yml` - update if needed +3. **Verify vault** contains `vault_cloudflare_api_token` +4. **Test connectivity**: + ```bash + ansible -i inventory.yml illuminations.wdw-labs.cloud -m ping + ``` +5. **Run the playbook**: + ```bash + ansible-playbook playbooks/deploy_paperclip_stack.yml -i inventory.yml -K + ``` + (The `-K` prompts for become password) + +**Note:** The playbook automatically calls the `docker-host` role first to ensure Docker is installed. No manual Docker setup needed. + +## Verification After Installation + +```bash +# Verify the role exists +ls -la roles/paperclip-docker/ + +# Verify host vars exist +cat host_vars/illuminations.wdw-labs.cloud/vars.yml + +# Verify playbook exists +cat playbooks/deploy_paperclip_stack.yml + +# Test syntax +ansible-playbook playbooks/deploy_paperclip_stack.yml --syntax-check + +# Dry run +ansible-playbook playbooks/deploy_paperclip_stack.yml -i inventory.yml --check +``` + +## Troubleshooting Installation + +### Role not found error +``` +ERROR! the role 'paperclip-docker' was not found +``` + +**Solution:** Ensure `roles/paperclip-docker/` exists with `tasks/main.yml` inside. + +### Template not found error +``` +ERROR! Unable to find template paperclip-docker/docker-compose.yml.j2 +``` + +**Solution:** Ensure templates are in `roles/paperclip-docker/templates/` with `.j2` extension. + +### Vault variable not found +``` +fatal: [illuminations]: FAILED! => error: Undefined variable: vault_cloudflare_api_token +``` + +**Solution:** Ensure `vault_cloudflare_api_token` is defined in your vault file: +```bash +ansible-vault view group_vars/all/vault +``` + +### Host not in inventory +``` +ERROR! Unable to parse illuminations.wdw-labs.cloud as an inventory address +``` + +**Solution:** Ensure your `inventory.yml` contains the host: +```yaml +all: + hosts: + illuminations.wdw-labs.cloud: + ansible_host: +``` + +## Files Reference + +All files are self-contained and ready to use. No modifications needed unless: + +- You want to change the domain (edit `host_vars/illuminations.wdw-labs.cloud/vars.yml`) +- You want to change ports (edit `roles/paperclip-docker/defaults/main.yml`) +- You want to change the certbot email (edit host_vars) + +For complete documentation, see `roles/paperclip-docker/README.md` after installation. diff --git a/ansible/roles/paperclip-docker/README.md b/ansible/roles/paperclip-docker/README.md new file mode 100644 index 0000000..6aa6f0d --- /dev/null +++ b/ansible/roles/paperclip-docker/README.md @@ -0,0 +1,136 @@ +--- +- name: Install Docker SDK for Python + apt: + name: python3-docker + state: present + become: yes + +- 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: yes + +- name: Verify docker-compose installation + shell: /usr/local/bin/docker-compose --version + register: compose_version + become: yes + +- name: Show docker-compose version + debug: + msg: "{{ compose_version.stdout }}" + +- name: Create paperclip directories + file: + path: "{{ item }}" + state: directory + mode: '0755' + owner: root + group: root + loop: + - "{{ paperclip_base_path }}" + - "{{ paperclip_data_dir }}" + - "{{ paperclip_certs_dir }}" + - "{{ paperclip_base_path }}/pgdata" + become: yes + +- name: Generate BETTER_AUTH_SECRET + shell: openssl rand -hex 24 + register: better_auth_secret + changed_when: false + no_log: yes + +- name: Generate PAPERCLIP_AGENT_JWT_SECRET + shell: openssl rand -hex 24 + register: paperclip_jwt_secret + changed_when: false + no_log: yes + +- name: Write secrets and configuration to .env + copy: + content: | + # Authentication secrets (auto-generated during deployment) + BETTER_AUTH_SECRET={{ better_auth_secret.stdout }} + PAPERCLIP_AGENT_JWT_SECRET={{ paperclip_jwt_secret.stdout }} + + # Infrastructure configuration + CLOUDFLARE_API_TOKEN={{ vault_cloudflare_api_token }} + CERTBOT_EMAIL={{ certbot_email }} + PAPERCLIP_DOMAIN={{ paperclip_domain }} + VLLM_ENDPOINT={{ vllm_endpoint }} + dest: "{{ paperclip_base_path }}/.env" + mode: '0600' + owner: root + group: root + become: yes + no_log: yes + +- name: Template docker-compose.yml + template: + src: docker-compose.yml.j2 + dest: "{{ paperclip_base_path }}/docker-compose.yml" + mode: '0644' + owner: root + group: root + become: yes + 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: yes + 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: yes + +- name: Stop and remove old containers + shell: | + cd {{ paperclip_base_path }} + /usr/local/bin/docker-compose down -v --remove-orphans 2>/dev/null || true + become: yes + ignore_errors: yes + +- name: Deploy docker compose stack + shell: | + cd {{ paperclip_base_path }} + /usr/local/bin/docker-compose up -d + become: yes + register: compose_result + +- name: Wait for Paperclip to be ready + uri: + url: "http://localhost:{{ paperclip_port }}/health" + status_code: 200 + register: result + until: result.status == 200 + retries: 30 + delay: 5 + ignore_errors: yes + +- 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 + delay: 5 + ignore_errors: yes + +- 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] + \ No newline at end of file diff --git a/ansible/roles/paperclip-docker/defaults/main.yml b/ansible/roles/paperclip-docker/defaults/main.yml new file mode 100644 index 0000000..841cff0 --- /dev/null +++ b/ansible/roles/paperclip-docker/defaults/main.yml @@ -0,0 +1,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: 127.0.0.1 + +# 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: "local_trusted" + +# Reverse proxy configuration +nginx_port: 443 +nginx_http_port: 80 + +# HTTPS/SSL configuration +certbot_email: admin@wdw-labs.cloud +certbot_provider: cloudflare + +# Inference endpoint (vLLM or remote OpenAI-compatible) +vllm_endpoint: "http://127.0.0.1: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 diff --git a/ansible/roles/paperclip-docker/handlers/main.yml b/ansible/roles/paperclip-docker/handlers/main.yml new file mode 100644 index 0000000..cb533cb --- /dev/null +++ b/ansible/roles/paperclip-docker/handlers/main.yml @@ -0,0 +1,19 @@ +--- +- 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 + \ No newline at end of file diff --git a/ansible/roles/paperclip-docker/tasks/main.yml b/ansible/roles/paperclip-docker/tasks/main.yml new file mode 100644 index 0000000..f25a5b0 --- /dev/null +++ b/ansible/roles/paperclip-docker/tasks/main.yml @@ -0,0 +1,142 @@ +--- +- name: Install Docker SDK for Python + apt: + name: python3-docker + state: present + become: yes + +- 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: yes + +- name: Verify docker-compose installation + shell: /usr/local/bin/docker-compose --version + register: compose_version + become: yes + +- name: Show docker-compose version + debug: + msg: "{{ compose_version.stdout }}" + +- name: Create paperclip directories + file: + path: "{{ item }}" + state: directory + mode: '0755' + owner: root + group: root + loop: + - "{{ paperclip_base_path }}" + - "{{ paperclip_data_dir }}" + - "{{ paperclip_certs_dir }}" + - "{{ paperclip_base_path }}/pgdata" + become: yes + +- 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 + become: yes + +- name: Generate BETTER_AUTH_SECRET + shell: openssl rand -hex 24 + register: better_auth_secret + changed_when: false + no_log: yes + +- name: Generate PAPERCLIP_AGENT_JWT_SECRET + shell: openssl rand -hex 24 + register: paperclip_jwt_secret + changed_when: false + no_log: yes + +- name: Write secrets and configuration to .env + copy: + content: | + # Authentication secrets (auto-generated during deployment) + BETTER_AUTH_SECRET={{ better_auth_secret.stdout }} + PAPERCLIP_AGENT_JWT_SECRET={{ paperclip_jwt_secret.stdout }} + + # Infrastructure configuration + CLOUDFLARE_API_TOKEN={{ vault_cloudflare_api_token }} + CERTBOT_EMAIL={{ certbot_email }} + PAPERCLIP_DOMAIN={{ paperclip_domain }} + VLLM_ENDPOINT={{ vllm_endpoint }} + dest: "{{ paperclip_base_path }}/.env" + mode: '0600' + owner: root + group: root + become: yes + no_log: yes + +- name: Template docker-compose.yml + template: + src: docker-compose.yml.j2 + dest: "{{ paperclip_base_path }}/docker-compose.yml" + mode: '0644' + owner: root + group: root + become: yes + 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: yes + 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: yes + +- name: Stop and remove old containers + shell: | + cd {{ paperclip_base_path }} + /usr/local/bin/docker-compose down --remove-orphans 2>/dev/null || true + become: yes + ignore_errors: yes + +- name: Deploy docker compose stack + shell: | + cd {{ paperclip_base_path }} + /usr/local/bin/docker-compose up -d + become: yes + register: compose_result + +- name: Wait for Paperclip to be ready + uri: + url: "http://localhost:{{ paperclip_port }}/health" + status_code: 200 + register: result + until: result.status == 200 + retries: 30 + delay: 5 + ignore_errors: yes + +- 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 + delay: 5 + ignore_errors: yes + +- 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] + \ No newline at end of file diff --git a/ansible/roles/paperclip-docker/templates/certbot-init.sh.j2 b/ansible/roles/paperclip-docker/templates/certbot-init.sh.j2 new file mode 100644 index 0000000..ebac5c9 --- /dev/null +++ b/ansible/roles/paperclip-docker/templates/certbot-init.sh.j2 @@ -0,0 +1,65 @@ +#!/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 diff --git a/ansible/roles/paperclip-docker/templates/docker-compose.yml.j2 b/ansible/roles/paperclip-docker/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..fc50869 --- /dev/null +++ b/ansible/roles/paperclip-docker/templates/docker-compose.yml.j2 @@ -0,0 +1,90 @@ +services: + db: + image: postgres:16-alpine + container_name: paperclip-db + network_mode: host + environment: + POSTGRES_USER: paperclip + POSTGRES_PASSWORD: paperclip + POSTGRES_DB: paperclip + volumes: + - {{ paperclip_base_path }}/pgdata:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paperclip -d paperclip"] + interval: 10s + timeout: 5s + retries: 5 + + paperclip: + image: reeoss/paperclipai-paperclip:latest + container_name: paperclip + network_mode: host + env_file: + - {{ paperclip_base_path }}/.env + environment: + HOST: "127.0.0.1" + PAPERCLIP_HOME: /paperclip + PAPERCLIP_PORT: {{ paperclip_port }} + PAPERCLIP_PUBLIC_URL: "https://{{ paperclip_domain }}" + PAPERCLIP_DEPLOYMENT_MODE: "{{ paperclip_deployment_mode }}" + PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" + PAPERCLIP_ALLOWED_HOSTNAMES: "{{ paperclip_allowed_hostnames }}" + DATABASE_URL: "postgresql://paperclip:paperclip@localhost:5432/paperclip" + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + PAPERCLIP_AGENT_JWT_SECRET: ${PAPERCLIP_AGENT_JWT_SECRET} + VLLM_ENDPOINT: ${VLLM_ENDPOINT} + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + volumes: + - {{ paperclip_data_dir }}:/paperclip + depends_on: + db: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:{{ paperclip_port }}/health"] + interval: 30s + 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 + \ No newline at end of file diff --git a/ansible/roles/paperclip-docker/templates/nginx.conf.j2 b/ansible/roles/paperclip-docker/templates/nginx.conf.j2 new file mode 100644 index 0000000..e0999bd --- /dev/null +++ b/ansible/roles/paperclip-docker/templates/nginx.conf.j2 @@ -0,0 +1,86 @@ +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; + } + } +}