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)
This commit is contained in:
1 parent
8778a3bbb0
commit
f701ad823f
13 files changed
+1093
-124
No files matched your search
@@ -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 }}"
|
||||
@@ -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 <think> 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: <ip_address>
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user