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
This commit is contained in:
715
SKILL.md
Normal file
715
SKILL.md
Normal file
@@ -0,0 +1,715 @@
|
||||
---
|
||||
name: _deploy_app
|
||||
description: 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
|
||||
|
||||
```mermaid
|
||||
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}
|
||||
|
||||
4. 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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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](#step-4b-redeploy-workflow).
|
||||
**If `deployed` is false** --> go to [Step 4a: New Deploy Workflow](#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:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
services:
|
||||
{app_name}:
|
||||
# ... service config ...
|
||||
networks:
|
||||
- proxy
|
||||
- default
|
||||
```
|
||||
|
||||
4. Pin all image versions -- never use `latest`.
|
||||
5. Set `restart: unless-stopped` on all services.
|
||||
|
||||
### 4a.3: Ensure .env
|
||||
|
||||
If `.env` does not exist:
|
||||
|
||||
1. Generate with real random secrets:
|
||||
```bash
|
||||
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:
|
||||
|
||||
```makefile
|
||||
.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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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:**
|
||||
```bash
|
||||
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:**
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
# On server:
|
||||
cd {profile.deploy_path} && git clone git@{gitea_ssh_host}:{owner}/{app_name}.git
|
||||
```
|
||||
|
||||
If the directory already exists, pull instead:
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
# On server:
|
||||
cd {profile.deploy_path}/{app_name}
|
||||
```
|
||||
|
||||
**If a Dockerfile exists** (custom build):
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
**If only pre-built images** (no Dockerfile):
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
### 4a.15: Add Gitea Webhook
|
||||
|
||||
Create a push webhook so future `git push` events trigger auto-deploy:
|
||||
|
||||
```bash
|
||||
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:**
|
||||
```bash
|
||||
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:**
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "{descriptive message based on changed files}"
|
||||
```
|
||||
|
||||
### 4b.3: Push to Remote
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
If no remote named `origin` exists pointing to the Gitea host, add it first:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
sleep 5
|
||||
```
|
||||
|
||||
### 4b.5: Verify
|
||||
|
||||
Check the deploy listener logs on the server:
|
||||
|
||||
```bash
|
||||
# On server:
|
||||
journalctl -u deploy-listener -n 10 --no-pager
|
||||
```
|
||||
|
||||
Curl the live domain:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" https://{domain}/
|
||||
```
|
||||
|
||||
Check container status:
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
```json
|
||||
{
|
||||
"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`:**
|
||||
```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`:**
|
||||
```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`:
|
||||
|
||||
```json
|
||||
{
|
||||
"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.
|
||||
Reference in New Issue
Block a user