Deploy new apps or push updates to existing deployments via Docker Compose + Caddy + Gitea webhooks. Multi-server profiles, auto-detection of deployment status, full infrastructure provisioning. - SKILL.md: 715-line workflow documentation - scripts/detect_deployment.py: deployment status detection - scripts/validate_compose.py: compose file validation - references/: infrastructure, compose patterns, Caddy patterns - assets/: Makefile and compose templates - config.json: mew server profile
22 KiB
name, description
| name | description |
|---|---|
| _deploy_app | Deploy a new app or push updates to an existing deployment. Detects deployment status, provisions infrastructure (Gitea repo, DNS, Caddy, webhook), and deploys Docker Compose stacks. Supports multiple servers via profiles. Use when the user says "deploy this", "push to production", "deploy to mew", or invokes "/_deploy_app". |
/_deploy_app
Deploy a new application to a production server or push updates to an existing deployment. Detect deployment status automatically, provision all required infrastructure (Gitea repo, Cloudflare DNS, Caddy reverse proxy, webhook auto-deploy), and bring Docker Compose stacks online.
When to Use
- Deploy a new app to a production server for the first time
- Push updates to an existing deployment (triggers auto-deploy via webhook)
- Check deployment status of the current project
- Trigger phrases: "deploy this", "push to production", "deploy to mew",
/_deploy_app
Prerequisites
| Requirement | Details |
|---|---|
| SSH access | ssh mew (or target server alias) must work without password prompt |
| Gitea secrets | ~/.claude/secrets/gitea.json with token, url, owner, ssh_host |
| Cloudflare secrets | ~/.claude/secrets/cloudflare.json with token, zones (domain-to-zone_id map) |
| Docker Compose | Project must have docker-compose.yaml (or this skill helps generate one) |
| Webhook secret | Stored in ~/.claude/secrets/gitea.json as webhook_secret |
If any secrets file is missing, stop and tell the user which file is needed and what keys it must contain.
Workflow Overview
flowchart TD
A[📋 Load Server Profile] --> B[🔍 Detect Project]
B --> C{🐍 Run detect_deployment.py}
C -->|Not Deployed| D[🆕 New Deploy Workflow]
C -->|Already Deployed| E[🔄 Redeploy Workflow]
D --> D1[Ensure Compose + .env] --> D2[Create Gitea Repo]
D2 --> D3[Git Init + Push] --> D4[Create DNS Record]
D4 --> D5[Clone on Server] --> D6[Add to Deploy Map]
D6 --> D7[Add Caddy Site Block] --> D8[Docker Compose Up]
D8 --> D9[Add Webhook] --> F
E --> E1[Commit Changes] --> E2[Git Push]
E2 --> E3[Wait for Webhook] --> F
F[✅ Verify & Report]
style D fill:#c8e6c9
style E fill:#bbdefb
style F fill:#fff9c4
Step 1: Load Server Profile
Read ~/.claude/skills/_deploy_app/config.json to determine the target server.
If config.json exists
- Read the file and parse the
active_profilekey. - If
--profile=namewas passed, use that profile instead. Error if it does not exist. - Display a summary:
🎯 Deployment target: {profile.name} 🌐 Domain: *.{profile.domain} 🖥️ Server: {profile.ssh_host} ({profile.ssh_user}@{profile.ssh_host}) 🔀 Proxy: {profile.proxy_type} 📁 Deploy path: {profile.deploy_path}
- Ask: "Proceed with this profile, or switch to another?"
If config.json is missing
Walk the user through creating their first profile:
| # | Question | Default |
|---|---|---|
| 1 | Profile name (short ID, e.g. mew) |
(required) |
| 2 | Description (human-readable) | (required) |
| 3 | SSH host alias (e.g. mew) |
(required) |
| 4 | SSH user | darren |
| 5 | Server hostname (for local detection) | (required) |
| 6 | Server IP (for DNS A records) | (required) |
| 7 | Wildcard domain (e.g. lavender.spl.tech) |
(required) |
| 8 | Deploy path on server | /srv/git |
| 9 | Proxy type (caddy or none) |
caddy |
| 10 | Caddy compose path (skip if none) | /data/docker/caddy |
| 11 | Caddy container name (skip if none) | caddy |
| 12 | Docker proxy network name | proxy |
| 13 | Gitea host (e.g. git.lavender-daydream.com) |
(from secrets) |
Write config.json, confirm, then proceed.
Profile variables reference
| Variable | Description | Example |
|---|---|---|
{profile.name} |
Human-readable name | Mew Server |
{profile.ssh_host} |
SSH alias or hostname | mew |
{profile.ssh_user} |
SSH login user | darren |
{profile.server_hostname} |
Actual hostname (for local detection) | mew |
{profile.server_ip} |
Public IP address | 155.94.170.136 |
{profile.domain} |
Wildcard domain | lavender.spl.tech |
{profile.deploy_path} |
Root path for deployed repos | /srv/git |
{profile.proxy_type} |
caddy or none |
caddy |
{profile.caddy_compose_path} |
Caddy's docker-compose directory | /data/docker/caddy |
{profile.caddy_container} |
Caddy container name | caddy |
{profile.proxy_network} |
Docker network for proxy traffic | proxy |
Execution context detection
Determine whether commands run locally or remotely:
current_host=$(hostname)
- If
current_hostmatches{profile.server_hostname}--> local execution (run commands directly) - If no match --> remote execution (wrap commands in
ssh {profile.ssh_user}@{profile.ssh_host} "command")
Store this decision as run_on_server for use throughout the workflow.
Step 2: Detect Project
Scan the current working directory to gather project metadata.
- Detect app name -- use the directory basename, lowercased and hyphenated.
- Check for git remote -- if
originexists, extract the repo name from the URL. - Detect project type -- look for these files (in order):
docker-compose.yaml/docker-compose.yml/compose.yamlDockerfilepackage.jsonrequirements.txt/pyproject.tomlgo.mod
- Determine container port -- parse the compose file for
ports:mapping orEXPOSEin Dockerfile. Default to3000if not detectable. - Determine container name -- from the compose file's main service, or
{app_name}as fallback.
Step 3: Check Deployment Status
Run the detection script to determine if this app is already deployed:
python3 ~/.claude/skills/_deploy_app/scripts/detect_deployment.py \
--repo-name {owner}/{app_name} \
--config ~/.claude/skills/_deploy_app/config.json
Parse the JSON output:
| Field | Meaning |
|---|---|
deployed |
true if the app exists on the server |
gitea_repo_exists |
true if the Gitea repo exists |
dns_exists |
true if the DNS record exists |
caddy_configured |
true if Caddy has a site block |
webhook_exists |
true if the Gitea webhook is configured |
container_running |
true if the Docker container is up |
If deployed is true --> go to Step 4b: Redeploy Workflow.
If deployed is false --> go to Step 4a: New Deploy Workflow.
Step 4a: New Deploy Workflow
Execute all substeps in order. Each substep is idempotent -- skip if the resource already exists.
4a.1: Confirm Domain
Propose a default domain and ask the user to confirm or override:
Proposed domain:
{app_name}.{profile.domain}Accept this domain, or provide a different one?
Store the confirmed domain as {domain}.
4a.2: Ensure docker-compose.yaml
If no compose file exists in the project:
- Read
~/.claude/skills/_compose/references/proxy-patterns.mdfor Caddy patterns. - Generate a compose file appropriate for the detected project type.
- MUST include the proxy network as an external network:
networks:
proxy:
external: true
services:
{app_name}:
# ... service config ...
networks:
- proxy
- default
- Pin all image versions -- never use
latest. - Set
restart: unless-stoppedon all services.
4a.3: Ensure .env
If .env does not exist:
- Generate with real random secrets:
openssl rand -hex 16 - Include all required environment variables with sensible defaults.
- Group by section with comments (e.g.
# === Database ===).
4a.4: Ensure .env.example
Copy .env and replace all secret values with descriptive placeholders:
DB_PASSWORD=changeme-use-a-strong-password
SECRET_KEY=generate-with-openssl-rand-hex-32
4a.5: Ensure Makefile
Copy from ~/.claude/skills/_deploy_app/assets/Makefile.template if it exists, otherwise generate:
.PHONY: up down logs pull restart ps
up:
docker compose up -d
down:
docker compose down
logs:
docker compose logs -f
pull:
docker compose pull
restart:
docker compose restart
ps:
docker compose ps
4a.6: Ensure .gitignore
At minimum, include:
.env
*.log
⚠️ IMPORTANT: Do NOT modify an existing .gitignore without explicit user permission (per global guardrails).
4a.7: Validate Compose File
Run the compose validation script:
python3 ~/.claude/skills/_compose/scripts/validate-compose.py \
./docker-compose.yaml --strict
Fix all errors before proceeding. Review warnings and fix where appropriate.
4a.8: Create Gitea Repository
Read the Gitea token from secrets and create the repo:
GITEA_TOKEN=$(python3 -c "import json; print(json.load(open('$HOME/.claude/secrets/gitea.json'))['token'])")
GITEA_URL=$(python3 -c "import json; print(json.load(open('$HOME/.claude/secrets/gitea.json'))['url'])")
GITEA_OWNER=$(python3 -c "import json; print(json.load(open('$HOME/.claude/secrets/gitea.json'))['owner'])")
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/user/repos" \
-H "Content-Type: application/json" \
-d '{"name": "{app_name}", "private": false, "auto_init": false}'
If the repo already exists (HTTP 409), skip this step.
4a.9: Git Init and Push
git init -b main
git add -A
git commit -m "Initial commit"
git remote add origin git@{gitea_ssh_host}:{owner}/{app_name}.git
git push -u origin main
If git is already initialized, add the remote (if missing) and push.
4a.10: Create Cloudflare DNS Record
Read the Cloudflare token and zone ID, then create an A record:
CF_TOKEN=$(python3 -c "import json; print(json.load(open('$HOME/.claude/secrets/cloudflare.json'))['token'])")
Determine the zone ID by matching the domain's root against the zones map in cloudflare.json.
Check if record already exists:
curl -s -H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type=A&name={domain}"
If no record exists, create one:
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"A","name":"{domain}","content":"{profile.server_ip}","ttl":1,"proxied":false}'
proxied: false-- Caddy handles TLS via Let's Encrypt; Cloudflare proxy would interfere.ttl: 1-- automatic TTL.
If record exists but points to the wrong IP, update it with PUT.
4a.11: Clone Repository on Server
Use the run_on_server helper:
# On server:
cd {profile.deploy_path} && git clone git@{gitea_ssh_host}:{owner}/{app_name}.git
If the directory already exists, pull instead:
# On server:
cd {profile.deploy_path}/{app_name} && git pull origin main
4a.12: Add to Deploy Map
Read the current deploy map, add the new entry, and write back:
# On server:
jq '. + {"{owner}/{app_name}": "{profile.deploy_path}/{app_name}"}' \
/etc/deploy-listener/deploy-map.json \
| sudo tee /etc/deploy-listener/deploy-map.json.tmp \
&& sudo mv /etc/deploy-listener/deploy-map.json.tmp /etc/deploy-listener/deploy-map.json
If /etc/deploy-listener/deploy-map.json does not exist, create it with just this entry.
4a.13: Add Caddy Site Block
⚠️ Skip this step if {profile.proxy_type} is none. Note to the user: "Reverse proxy is set to none -- configure your own proxy to point to this stack."
Append a new site block to the Caddyfile on the server:
# === {App Name} ===
{domain} {
encode zstd gzip
reverse_proxy {container_name}:{port}
}
Where {container_name} is the main app container and {port} is its internal port.
Caddyfile location: {profile.caddy_compose_path}/Caddyfile
After appending, restart Caddy:
# On server:
cd {profile.caddy_compose_path} && docker compose restart {profile.caddy_container}
4a.14: Deploy the Stack
On the server, bring up the Docker Compose stack:
# On server:
cd {profile.deploy_path}/{app_name}
If a Dockerfile exists (custom build):
docker compose up -d --build
If only pre-built images (no Dockerfile):
docker compose pull && docker compose up -d
4a.15: Add Gitea Webhook
Create a push webhook so future git push events trigger auto-deploy:
WEBHOOK_SECRET=$(python3 -c "import json; print(json.load(open('$HOME/.claude/secrets/gitea.json'))['webhook_secret'])")
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/repos/{owner}/{app_name}/hooks" \
-H "Content-Type: application/json" \
-d '{
"type": "gitea",
"active": true,
"branch_filter": "main master",
"config": {
"url": "https://deploy.{profile.domain}/webhook",
"content_type": "json",
"secret": "'"$WEBHOOK_SECRET"'"
},
"events": ["push"]
}'
4a.16: Verify Deployment
Wait 10 seconds for TLS certificate provisioning, then verify:
HTTP check:
curl -s -o /dev/null -w "%{http_code}" https://{domain}/
| Code | Meaning |
|---|---|
200 |
✅ Deployment verified |
301/302 |
✅ Redirect -- likely working (SSL or app redirect) |
502 |
❌ Caddy cannot reach container -- check proxy network and container status |
0 / timeout |
❌ DNS not propagated or Caddy not restarted |
Container check:
# On server:
docker ps --filter name={container_name} --format '{{.Status}}'
4a.17: Report
Display a deployment summary:
✅ Deployment complete!
Item Status 🌐 URL https://{domain} 🐳 Container {status from docker ps} 📦 Gitea repo {gitea_url}/{owner}/{app_name} 🪝 Webhook Active (auto-deploy on push to main) 📡 DNS {domain} → {profile.server_ip}
Step 4b: Redeploy Workflow
For apps that are already deployed, the workflow is simplified: commit, push, and let the webhook handle the rest.
4b.1: Check for Uncommitted Changes
git status --porcelain
If output is non-empty, display the changes and ask:
"There are uncommitted changes. Commit and deploy, or abort?"
4b.2: Commit Changes
If the user confirms:
git add -A
git commit -m "{descriptive message based on changed files}"
4b.3: Push to Remote
git push origin main
If no remote named origin exists pointing to the Gitea host, add it first:
git remote add origin git@{gitea_ssh_host}:{owner}/{app_name}.git
git push -u origin main
4b.4: Wait for Webhook
Wait 5 seconds for the webhook to fire and the deploy listener to process:
sleep 5
4b.5: Verify
Check the deploy listener logs on the server:
# On server:
journalctl -u deploy-listener -n 10 --no-pager
Curl the live domain:
curl -s -o /dev/null -w "%{http_code}" https://{domain}/
Check container status:
# On server:
docker ps --filter name={container_name} --format '{{.Status}}'
4b.6: Report
🔄 Redeployment complete! 🌐 URL: https://{domain} 🐳 Container: {status} 🪝 Triggered via: webhook (push to main)
Helper: run_on_server
All server-side commands use this pattern for execution context:
def run_on_server(command):
if is_local:
# hostname matches profile.server_hostname
run(command)
else:
# wrap in SSH
run(f'ssh {profile.ssh_user}@{profile.ssh_host} "{command}"')
When constructing SSH commands:
- Escape double quotes inside the command.
- For multi-line commands, use
ssh ... 'bash -s' << 'EOF'heredoc syntax. - For commands requiring sudo, ensure the SSH user has passwordless sudo configured for the needed commands.
Configuration
config.json Structure
{
"active_profile": "mew",
"profiles": {
"mew": {
"name": "Mew Server",
"ssh_host": "mew",
"ssh_user": "darren",
"server_hostname": "mew",
"server_ip": "155.94.170.136",
"domain": "lavender.spl.tech",
"deploy_path": "/srv/git",
"proxy_type": "caddy",
"caddy_compose_path": "/data/docker/caddy",
"caddy_container": "caddy",
"proxy_network": "proxy"
}
}
}
Secrets Files
~/.claude/secrets/gitea.json:
{
"token": "gitea-api-token",
"url": "https://git.lavender-daydream.com",
"ssh_host": "git.lavender-daydream.com",
"owner": "darren",
"webhook_secret": "shared-secret-for-webhooks"
}
~/.claude/secrets/cloudflare.json:
{
"token": "cloudflare-api-bearer-token",
"zones": {
"lavender.spl.tech": "zone-id-here",
"spl.tech": "zone-id-here"
}
}
Adding a New Server Profile
Follow these steps to add a second (or third) deployment target.
1. Prepare the Server
On the new server:
- Install Docker and Docker Compose.
- Set up the deploy listener (
deploy-listener.py) as a systemd service. - Create the deploy map:
sudo mkdir -p /etc/deploy-listener && echo '{}' | sudo tee /etc/deploy-listener/deploy-map.json - Set up Caddy (or chosen reverse proxy) with Docker Compose.
- Create the deploy path:
sudo mkdir -p /srv/git - Ensure SSH key access from the workstation (
ssh new-servermust work). - Ensure the server can clone from Gitea (add SSH key to Gitea if needed).
2. Add the Profile
Edit ~/.claude/skills/_deploy_app/config.json and add a new entry under profiles:
{
"active_profile": "mew",
"profiles": {
"mew": { "...existing..." },
"new-server": {
"name": "New Server Description",
"ssh_host": "new-server",
"ssh_user": "darren",
"server_hostname": "new-server",
"server_ip": "1.2.3.4",
"domain": "new.example.com",
"deploy_path": "/srv/git",
"proxy_type": "caddy",
"caddy_compose_path": "/data/docker/caddy",
"caddy_container": "caddy",
"proxy_network": "proxy"
}
}
}
3. Deploy to the New Server
Use the --profile flag:
/_deploy_app --profile=new-server
Or set active_profile to the new server name in config.json.
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Caddy 502 Bad Gateway | Container not on the proxy network, or container not started |
Verify: docker network inspect proxy -- check the app container is listed. Run docker network connect proxy {container_name} if missing. |
| Caddy 502 after restart | Caddy restarted before container was ready | Wait for container healthcheck, then restart Caddy: docker compose restart caddy |
| Webhook not firing | Webhook misconfigured or deploy listener down | Check Gitea webhook delivery history: Gitea UI → Repo → Settings → Webhooks → Recent Deliveries. Check deploy listener: systemctl status deploy-listener |
| DNS not resolving | Cloudflare propagation delay or wrong zone | Verify with dig {domain}. Check Cloudflare dashboard. Propagation is usually instant but can take up to 5 minutes. |
| Git push rejected | Remote URL incorrect or SSH key not authorized | Verify remote: git remote -v. Test SSH: ssh -T git@{gitea_ssh_host}. Check Gitea deploy keys. |
| Deploy listener not running | Service crashed or not enabled | Check: systemctl status deploy-listener. Restart: sudo systemctl restart deploy-listener. Enable: sudo systemctl enable deploy-listener. |
| Container exits immediately | Missing .env, bad config, or port conflict | Check logs: docker compose logs {service}. Verify .env exists on server. Check port conflicts: ss -tlnp | grep {port}. |
| TLS cert not provisioned | DNS not pointed, or rate limited | Caddy auto-provisions via Let's Encrypt. Verify DNS resolves first. Check Caddy logs: docker compose logs caddy. Let's Encrypt rate limits: 50 certs per domain per week. |
| Permission denied on server | SSH user lacks sudo or file ownership wrong | Verify user is in the git group: groups darren. Check file ownership: ls -la {deploy_path}/{app_name}. |
Important Rules
- Always load and confirm the server profile before doing anything else.
- Always run
detect_deployment.pybefore choosing the new-deploy or redeploy path. - Never start Docker containers without explicit user confirmation on first deploy.
- Always create real random secrets in
.env-- never use placeholder passwords. - Always pin Docker image versions -- never use
latest. - Always include the proxy network in generated compose files.
- Always verify the deployment with both an HTTP check and a container status check.
- Never modify
.gitignorewithout explicit user permission. - Check all git remotes for public providers (github.com, gitlab.com, etc.) before pushing -- warn the user if found.
- If
config.jsonis modified, write it back immediately. - Prefer the
_composeskill'svalidate-compose.pyscript for compose file validation.
Resources
scripts/
detect_deployment.py-- Check Gitea API, Cloudflare DNS, server filesystem, and Docker status to determine if an app is already deployed. Return structured JSON. (To be created.)validate_compose.py-- Delegate to~/.claude/skills/_compose/scripts/validate-compose.py.
references/
compose-patterns.md-- Common Docker Compose patterns for different app types (Node.js, Python, Go, static sites). (To be created.)- Reuse
~/.claude/skills/_compose/references/proxy-patterns.mdfor proxy configuration guidance. - Reuse
~/.claude/skills/_compose/references/troubleshooting.mdfor Docker troubleshooting.
assets/
Makefile.template-- Standard Makefile for deployed apps. (To be created.)
Cross-Platform Notes
- All API calls use
curl, available on both Windows and Linux. - Python scripts use
#!/usr/bin/env python3for portability. - SSH commands work from both WSL and native Linux.
- Use
$HOME(not~) in scripts for compatibility. - Path separators: always forward slashes.