Files
skill-deploy-app/SKILL.md
Darren Neese 994332a3f0 Initial commit: _deploy_app skill
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
2026-03-25 21:12:30 -04:00

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

  1. Read the file and parse the active_profile key.
  2. If --profile=name was passed, use that profile instead. Error if it does not exist.
  3. 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}

  1. 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_host matches {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.

  1. Detect app name -- use the directory basename, lowercased and hyphenated.
  2. Check for git remote -- if origin exists, extract the repo name from the URL.
  3. Detect project type -- look for these files (in order):
    • docker-compose.yaml / docker-compose.yml / compose.yaml
    • Dockerfile
    • package.json
    • requirements.txt / pyproject.toml
    • go.mod
  4. Determine container port -- parse the compose file for ports: mapping or EXPOSE in Dockerfile. Default to 3000 if not detectable.
  5. 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:

  1. Read ~/.claude/skills/_compose/references/proxy-patterns.md for Caddy patterns.
  2. Generate a compose file appropriate for the detected project type.
  3. MUST include the proxy network as an external network:
networks:
  proxy:
    external: true

services:
  {app_name}:
    # ... service config ...
    networks:
      - proxy
      - default
  1. Pin all image versions -- never use latest.
  2. Set restart: unless-stopped on all services.

4a.3: Ensure .env

If .env does not exist:

  1. Generate with real random secrets:
    openssl rand -hex 16
    
  2. Include all required environment variables with sensible defaults.
  3. 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:

  1. Install Docker and Docker Compose.
  2. Set up the deploy listener (deploy-listener.py) as a systemd service.
  3. Create the deploy map: sudo mkdir -p /etc/deploy-listener && echo '{}' | sudo tee /etc/deploy-listener/deploy-map.json
  4. Set up Caddy (or chosen reverse proxy) with Docker Compose.
  5. Create the deploy path: sudo mkdir -p /srv/git
  6. Ensure SSH key access from the workstation (ssh new-server must work).
  7. 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.py before 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 .gitignore without explicit user permission.
  • Check all git remotes for public providers (github.com, gitlab.com, etc.) before pushing -- warn the user if found.
  • If config.json is modified, write it back immediately.
  • Prefer the _compose skill's validate-compose.py script 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.md for proxy configuration guidance.
  • Reuse ~/.claude/skills/_compose/references/troubleshooting.md for 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 python3 for portability.
  • SSH commands work from both WSL and native Linux.
  • Use $HOME (not ~) in scripts for compatibility.
  • Path separators: always forward slashes.