Add Caddy reverse proxy for blumeops services (#55)

## Summary
- Add Caddy ansible role following zot pattern (manual build, ansible deploy)
- Caddy built with Gandi DNS plugin for ACME DNS-01 challenges
- Gandi PAT fetched from 1Password and written to secured file on indri
- Configure wildcard TLS for `*.ops.eblu.me`
- Initial services: forge, registry (indri-local)
- Uses port 8443 during testing to avoid Tailscale serve conflicts

## Build Instructions (already done)
On indri:
```bash
cd ~/code/3rd/caddy && mise run build
```

## Deployment and Testing
- [ ] Review Caddyfile configuration
- [ ] Run `mise run provision-indri -- --tags caddy` to deploy
- [ ] Test: `curl -v https://forge.ops.eblu.me:8443` (should get TLS cert)
- [ ] Test: `curl -v https://registry.ops.eblu.me:8443/v2/` (should return `{}`)
- [ ] Once verified, switch to port 443 and migrate services from Tailscale serve

## Files Changed
- `ansible/playbooks/indri.yml` - Add pre_task for Gandi PAT, add caddy role
- `ansible/roles/caddy/` - New role with Caddyfile and LaunchAgent templates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: https://forge.tail8d86e.ts.net/eblume/blumeops/pulls/55
This commit is contained in:
Erich Blume 2026-01-25 09:35:06 -08:00
commit 682a68dc9c
7 changed files with 222 additions and 0 deletions

View file

@ -78,6 +78,23 @@
no_log: true
tags: [forgejo_runner]
# Caddy Gandi token for ACME DNS-01 challenges
- name: Fetch Gandi PAT for Caddy
ansible.builtin.command:
cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mco6ka3dc3rmw7zkg2dhia5d2m --fields pat --reveal
delegate_to: localhost
register: _caddy_gandi_token
changed_when: false
no_log: true
check_mode: false
tags: [caddy]
- name: Set Caddy Gandi token fact
ansible.builtin.set_fact:
caddy_gandi_token: "{{ _caddy_gandi_token.stdout }}"
no_log: true
tags: [caddy]
roles:
- role: alloy
tags: alloy
@ -101,3 +118,5 @@
tags: tailscale-serve
- role: forgejo_runner
tags: forgejo_runner
- role: caddy
tags: caddy

View file

@ -0,0 +1,37 @@
---
# Caddy reverse proxy configuration
# Caddy is built manually from ~/code/3rd/caddy with the Gandi DNS plugin
caddy_repo_dir: /Users/erichblume/code/3rd/caddy
caddy_binary: "{{ caddy_repo_dir }}/bin/caddy"
caddy_config_dir: /Users/erichblume/.config/caddy
caddy_data_dir: /Users/erichblume/.local/share/caddy
caddy_log_dir: /Users/erichblume/Library/Logs
# Gandi API token file (written by ansible, chmod 0600)
# Caddy reads this file for ACME DNS-01 challenges
caddy_gandi_token_file: /Users/erichblume/.config/caddy/gandi-token
# Domain configuration
caddy_domain: ops.eblu.me
# Listen on Tailscale interface only (port 443)
# Use 8443 during testing to avoid conflicts with Tailscale serve
caddy_https_port: 8443
# Services to proxy
# Format: { name: "service", host: "hostname", backend: "url" }
caddy_services:
# Indri-local services
- name: forge
host: "forge.{{ caddy_domain }}"
backend: "http://localhost:3001"
- name: registry
host: "registry.{{ caddy_domain }}"
backend: "http://localhost:5050"
# K8s services (via minikube NodePort or ClusterIP)
# These will be configured once we determine the correct backend URLs
# - name: grafana
# host: "grafana.{{ caddy_domain }}"
# backend: "http://minikube-ip:nodeport"

View file

@ -0,0 +1,6 @@
---
- name: Restart caddy
ansible.builtin.shell: |
launchctl unload ~/Library/LaunchAgents/mcquack.eblume.caddy.plist 2>/dev/null || true
launchctl load ~/Library/LaunchAgents/mcquack.eblume.caddy.plist
changed_when: true

View file

@ -0,0 +1,80 @@
---
# Caddy reverse proxy deployment
# Binary is built manually - see ~/code/3rd/caddy/mise.toml
- name: Verify caddy binary exists
ansible.builtin.stat:
path: "{{ caddy_binary }}"
register: caddy_bin
failed_when: not caddy_bin.stat.exists
changed_when: false
- name: Create caddy config directory
ansible.builtin.file:
path: "{{ caddy_config_dir }}"
state: directory
mode: "0755"
- name: Create caddy data directory
ansible.builtin.file:
path: "{{ caddy_data_dir }}"
state: directory
mode: "0755"
- name: Fetch Gandi PAT (when running with --tags caddy)
ansible.builtin.command:
cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get mco6ka3dc3rmw7zkg2dhia5d2m --fields pat --reveal
delegate_to: localhost
register: _caddy_gandi_token_fallback
changed_when: false
no_log: true
check_mode: false
when: caddy_gandi_token is not defined
- name: Set Gandi token fact (fallback)
ansible.builtin.set_fact:
caddy_gandi_token: "{{ _caddy_gandi_token_fallback.stdout }}"
no_log: true
when: caddy_gandi_token is not defined
- name: Write Gandi token file
ansible.builtin.copy:
content: "{{ caddy_gandi_token }}"
dest: "{{ caddy_gandi_token_file }}"
mode: "0600"
no_log: true
notify: Restart caddy
- name: Deploy Caddyfile
ansible.builtin.template:
src: Caddyfile.j2
dest: "{{ caddy_config_dir }}/Caddyfile"
mode: "0644"
notify: Restart caddy
- name: Deploy caddy wrapper script
ansible.builtin.template:
src: caddy-wrapper.sh.j2
dest: "{{ caddy_config_dir }}/caddy-wrapper.sh"
mode: "0755"
notify: Restart caddy
- name: Deploy caddy LaunchAgent plist
ansible.builtin.template:
src: caddy.plist.j2
dest: ~/Library/LaunchAgents/mcquack.eblume.caddy.plist
mode: "0644"
notify: Restart caddy
- name: Check if caddy LaunchAgent is loaded
ansible.builtin.command:
cmd: launchctl list mcquack.eblume.caddy
register: caddy_launchctl
changed_when: false
failed_when: false
- name: Load caddy LaunchAgent
ansible.builtin.command:
cmd: launchctl load ~/Library/LaunchAgents/mcquack.eblume.caddy.plist
when: caddy_launchctl.rc != 0
changed_when: true

View file

@ -0,0 +1,38 @@
# Caddy reverse proxy for blumeops services
# Managed by ansible - do not edit manually
#
# All *.{{ caddy_domain }} requests are proxied to backend services.
# TLS certificates are obtained via ACME DNS-01 challenge using Gandi.
{
# Global options
admin off
}
# Wildcard certificate for all services
*.{{ caddy_domain }}:{{ caddy_https_port }} {
tls {
dns gandi {env.GANDI_BEARER_TOKEN}
}
{% for service in caddy_services %}
@{{ service.name }} host {{ service.host }}
handle @{{ service.name }} {
reverse_proxy {{ service.backend }}
}
{% endfor %}
# Fallback for unknown hosts
handle {
respond "Unknown service" 404
}
}
# Base domain (ops.eblu.me)
{{ caddy_domain }}:{{ caddy_https_port }} {
tls {
dns gandi {env.GANDI_BEARER_TOKEN}
}
respond "blumeops services - use a subdomain (e.g., forge.{{ caddy_domain }})"
}

View file

@ -0,0 +1,6 @@
#!/bin/bash
# Wrapper script for Caddy that loads the Gandi token from file
# Managed by ansible - do not edit manually
export GANDI_BEARER_TOKEN=$(cat {{ caddy_gandi_token_file }})
exec {{ caddy_binary }} run --config {{ caddy_config_dir }}/Caddyfile

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>mcquack.eblume.caddy</string>
<key>ProgramArguments</key>
<array>
<string>{{ caddy_config_dir }}/caddy-wrapper.sh</string>
</array>
<key>WorkingDirectory</key>
<string>{{ caddy_data_dir }}</string>
<key>EnvironmentVariables</key>
<dict>
<key>XDG_DATA_HOME</key>
<string>/Users/erichblume/.local/share</string>
<key>XDG_CONFIG_HOME</key>
<string>/Users/erichblume/.config</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{{ caddy_log_dir }}/mcquack.caddy.out.log</string>
<key>StandardErrorPath</key>
<string>{{ caddy_log_dir }}/mcquack.caddy.err.log</string>
</dict>
</plist>