# Docker Deployment Manager Unified REST API + Electron desktop app for managing Docker deployments across two servers via Gitea + Coolify. The API is the single source of truth — the UI, CLI, and LLM agents all use the same HTTP endpoints. ## Architecture ``` idea.llm.gitea.repo.docker.deployment/ ├── api/ # Express API server (:3100) │ ├── server.js # Entry point, Scalar docs at /reference │ ├── routes/ │ │ ├── coolify.js # /api/coolify/* — Coolify app management │ │ ├── projects.js # /api/projects/* — local scanning & comparison │ │ ├── servers.js # /api/servers/* — server CRUD, containers, logs │ │ └── docker.js # /api/docker/* — build, deploy, pull files │ ├── lib/ │ │ ├── config.js # Loads Coolify + deployment configs │ │ ├── coolify-client.js # coolifyFetch() — Coolify API wrapper │ │ └── ssh.js # SSHService class (exec + SFTP) │ └── openapi.json # OpenAPI 3.1 spec (24 endpoints) ├── app/ │ ├── main/ # Electron main process (legacy IPC) │ ├── renderer/ # React UI (Vite + TanStack Query) │ │ └── src/ │ │ ├── lib/api.js # All API calls via HTTP fetch │ │ ├── hooks/ # React Query hooks │ │ └── components/ │ │ ├── coolify/ # Coolify dashboard + deploy dialog │ │ ├── projects/ # Project management │ │ ├── servers/ # Server settings │ │ └── dashboard/ # Overview │ └── .env # SSH credentials (not committed) ├── cli/ # CLI tool for project scaffolding ├── deployment-config.json # Server list + project root config └── package.json ``` ## Quick Start ```bash # Install dependencies npm install cd app/renderer && npm install && cd ../.. # Run everything (API + Vite + Electron) npm run dev # Or without Electron (browser only at http://localhost:5173) npm run dev:no-electron # API only npm run api:dev ``` - **API server**: http://localhost:3100 - **Scalar API docs**: http://localhost:3100/reference - **OpenAPI spec**: http://localhost:3100/openapi.json - **Vite dev server**: http://localhost:5173 (proxies `/api/*` to :3100) ## API Endpoints (24 total) All endpoints return JSON. The Vite dev server proxies `/api/*` to the API server, so the renderer uses relative paths. ### Projects | Method | Path | Description | |--------|------|-------------| | GET | `/api/projects` | Scan local projects (finds Dockerfiles, coolify.json, etc.) | | POST | `/api/projects/compare` | Compare local vs remote files (docker-compose, .env, data/) | | POST | `/api/projects/init` | Initialize a project with deployment config via CLI | ### Servers | Method | Path | Description | |--------|------|-------------| | GET | `/api/servers` | List configured deployment servers | | POST | `/api/servers` | Add or update a server | | DELETE | `/api/servers/:id` | Delete a server | | GET | `/api/servers/:id/scan` | Scan remote ~/containers for deployed projects | | GET | `/api/servers/:id/containers` | List running Docker containers (docker ps) | | GET | `/api/servers/:id/logs` | Get container logs (query: containerName, remotePath, lines) | ### Docker Operations | Method | Path | Description | |--------|------|-------------| | POST | `/api/docker/build` | Build tar via build-image-tar.ps1 | | POST | `/api/docker/deploy` | Upload files + docker load + docker compose up | | POST | `/api/docker/pull` | Pull files/directories from remote server via SFTP | | POST | `/api/docker/vscode-diff` | Download remote file and open VS Code diff | ### Coolify | Method | Path | Description | |--------|------|-------------| | GET | `/api/coolify/apps` | List all Coolify apps (enriched with HOST_PORT) | | GET | `/api/coolify/apps/find/:name` | Find app by name | | POST | `/api/coolify/apps` | Create new Coolify application | | PATCH | `/api/coolify/apps/:uuid` | Update existing app | | DELETE | `/api/coolify/apps/:uuid` | Delete app | | GET | `/api/coolify/apps/:uuid/envs` | List env vars | | POST | `/api/coolify/apps/:uuid/envs` | Set env var | | POST | `/api/coolify/apps/:uuid/deploy` | Trigger deployment | | GET | `/api/coolify/servers` | List Coolify servers | | GET | `/api/coolify/next-port` | Get next available HOST_PORT | | POST | `/api/coolify/routes` | Add Traefik route via SSH | | DELETE | `/api/coolify/routes` | Remove Traefik route via SSH | | POST | `/api/coolify/drift` | Check drift between coolify.json and live state | | POST | `/api/coolify/upsert` | Full pipeline: config → create/update → env → route → webhook → deploy | | GET | `/api/coolify/config?path=...` | Read coolify.json from a project | ### Webhooks | Method | Path | Description | |--------|------|-------------| | POST | `/api/coolify/webhooks/setup` | Set Coolify webhook secret + create Gitea webhook on all apps | | DELETE | `/api/coolify/webhooks/teardown` | Remove webhook secret + delete Gitea webhooks | | GET | `/api/coolify/webhooks/status` | Check webhook configuration status per app | ### System | Method | Path | Description | |--------|------|-------------| | GET | `/api/health` | Health check | | GET | `/openapi.json` | OpenAPI 3.1 spec | | GET | `/reference` | Scalar interactive API docs | ## Configuration ### Coolify Config (`gitea.repo.management/config.json`) Shared with the gitea.repo.management project. Contains Coolify API credentials, server UUIDs, SSH config, and webhook/Gitea settings: ```json { "coolify": { "apiUrl": "http://192.168.69.4:2010", "apiToken": "", "serverUuid": "c88okoc4g4css0c4ooo04ko4", "projectUuid": "bcso4cocokswgo804gogoswo", "privateKeyUuid": "j0w08woc8s8c0sgok8ccow4w", "sshHost": "192.168.69.4", "sshUser": "clint", "sshPassword": "", "traefikPath": "/home/clint/containers/coolify/data/proxy/dynamic/custom.yml", "targetIp": "192.168.69.5", "domain": "dotrepo.com", "webhookSecret": "" }, "gitea": { "url": "http://192.168.69.4:2003", "token": "" } } ``` ### Deployment Config (`deployment-config.json`) Local to this project. Stores server list and project root: ```json { "servers": [ { "id": "1771305558920", "name": "Repo Server", "host": "192.168.69.5", "username": "clint", "useSudo": true } ], "projectsRoot": "C:\\.bucket\\repos.gitea" } ``` ### SSH Credentials (`app/.env`) ``` SSH_USERNAME=clint SSH_PASSWORD= ``` ## Coolify Upsert Pipeline The `POST /api/coolify/upsert` endpoint runs the full deploy pipeline: 1. **config** — Read coolify.json from the project 2. **server** — Resolve server name to UUID/IP via Coolify API 3. **check** — Find existing app by name 4. **validate** — Compare coolify.json vs live state, report diffs 5. **sync** — Create or update the Coolify application 6. **env** — Set HOST_PORT environment variable 7. **route** — Add Traefik route (remote) or set FQDN (local) 8. **changelog** — Write coolify.changelog.json to repo 9. **deploy** — Trigger Coolify deployment 10. **webhook** — Set Coolify webhook secret + create/update Gitea webhook for auto-deploy on push ## Docker Deploy Pipeline (Legacy/Direct) The `POST /api/docker/deploy` endpoint handles tar-based deployment: 1. Connect to server via SSH 2. Create remote directory (mkdir -p) 3. Upload tar + docker-compose.yml + .env + additional files via SFTP 4. Run `docker load -i .tar` 5. Run `docker compose down && docker compose up -d` 6. Poll health check (10 attempts, 2s apart) ## Infrastructure | Server | IP | Role | |--------|-----|------| | box-stableapps | 192.168.69.4 | Coolify host, Gitea, Traefik | | box-repoapps | 192.168.69.5 | Deployed apps | ### Port Allocation | Port | App | Status | |------|-----|--------| | 2001 | Audio Events | Deployed | | 2002 | Audio Capsule | Deployed | | 2003 | Audio Sphere | Deployed | | 2004 | Every Noise at Once | Deployed | | 2005 | Learn X In Y | Deployed | | 2006 | Actual Budget Report | Deployed | | 2007 | dotrepo-timer | Deployed | | 2008 | dotrepo-travel | Deployed | | 2009 | dotrepo-racker | Deployed | | 2011 | spike-breakdown | Deployed | | 2012 | dotrepo-site | Deployed | ## LLM / Agent Usage This API is designed to be called by LLMs and agents. Key endpoints for automation: ```bash # List all Coolify apps with their ports and status curl http://localhost:3100/api/coolify/apps # Deploy a project from its coolify.json curl -X POST http://localhost:3100/api/coolify/upsert \ -H "Content-Type: application/json" \ -d '{"projectPath": "C:/.bucket/repos.gitea/dotrepo.racker"}' # Scan local projects curl http://localhost:3100/api/projects # Deploy via tar+SSH to a server curl -X POST http://localhost:3100/api/docker/deploy \ -H "Content-Type: application/json" \ -d '{"projectPath": "C:/.bucket/repos.gitea/myapp", "serverId": "1771305558920", "remotePath": "~/containers/myapp"}' # Get container logs curl "http://localhost:3100/api/servers/1771305558920/logs?remotePath=~/containers/myapp&lines=50" ``` **Always use this API** to access/change Coolify and Gitea deployment state. Do not call the Coolify API directly — this API wraps it with enrichment (HOST_PORT), safety (drift detection, changelog), and routing (Traefik management). ## Webhook Auto-Deploy Every `git push` to Gitea automatically triggers a Coolify deployment. The pipeline: ``` git push → Gitea post-receive hook → Gitea webhook POST → Coolify webhook handler → Docker build + deploy ``` ### How It Works 1. Each Coolify app has a `manual_webhook_secret_gitea` — a shared HMAC-SHA256 secret 2. Each Gitea repo has a webhook pointing to `http://192.168.69.4:2010/webhooks/source/gitea/events/manual` 3. On push, Gitea signs the payload with the secret and POSTs to Coolify 4. Coolify validates the signature, matches the repo+branch, and queues a deployment ### Setup Webhooks are automatically configured during `upsert` (step 10). To set up webhooks on all existing apps: ```bash # Set up webhooks on all Coolify apps curl -X POST http://localhost:3100/api/coolify/webhooks/setup # Check status curl http://localhost:3100/api/coolify/webhooks/status # Remove all webhooks curl -X DELETE http://localhost:3100/api/coolify/webhooks/teardown ``` ### Key Details - **Webhook URL must use internal IP** (`http://192.168.69.4:2010`), NOT the public URL (`https://coolify.clintmasden.duckdns.org`). The public URL fails due to hairpin NAT — Gitea and Coolify are on the same box (192.168.69.4). - **Gitea ports**: 2003 = HTTP API, 2004 = SSH. Do not confuse them. - **Branch**: All repos use `master` (not `main`). Coolify's `git_branch` must match. The upsert pipeline defaults to `master`. ## Gotchas and Troubleshooting ### Docker Healthcheck IPv6 Issue Alpine Linux resolves `localhost` to `::1` (IPv6) before `127.0.0.1` (IPv4). If your container only listens on IPv4 (e.g., nginx on `0.0.0.0:3000`), healthchecks using `localhost` will fail silently. **Fix**: Always use `127.0.0.1` instead of `localhost` in healthcheck commands: ```yaml # docker-compose.yml healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] ``` ```dockerfile # Dockerfile HEALTHCHECK CMD wget -qO- http://127.0.0.1:3000/ || exit 1 ``` ### Gitea CIFS Hooks Not Executable Gitea's git data is stored on a CIFS/SMB mount (`//192.168.69.2/archive`). CIFS doesn't support per-file permissions — all files inherit `file_mode` from the mount options. If `file_mode=0664` (the default), git hooks won't execute because they lack the execute bit. This silently breaks: - Webhook delivery (post-receive hook doesn't fire) - Branch tracking (Gitea sees repos as "empty") - All git server-side hooks **Fix**: Change `file_mode=0664` to `file_mode=0775` in `/etc/fstab`: ``` //192.168.69.2/archive /home/clint/archive cifs ...,file_mode=0775,... 0 0 ``` Then remount: `sudo mount -o remount /home/clint/archive` After remounting, run `resync_all_hooks` via Gitea admin API to regenerate hook files: ```bash curl -X POST -H "Authorization: token " \ "http://192.168.69.4:2003/api/v1/admin/cron/resync_all_hooks" ``` ### Gitea Default Branch Mismatch All repos use `master` as their default branch. If Gitea's `default_branch` setting is `main` (the Gitea default for new repos), it will report the repo as `empty: true` and won't deliver webhooks for pushes to `master`. **Fix**: Set `default_branch` to `master` via API: ```bash curl -X PATCH -H "Authorization: token " \ -H "Content-Type: application/json" \ "http://192.168.69.4:2003/api/v1/repos/clint/" \ -d '{"default_branch":"master"}' ``` ### dockerComposeLocation vs baseDirectory In coolify.json, `dockerComposeLocation` is **relative to** `baseDirectory`. If your `baseDirectory` is `/myapp`, set `dockerComposeLocation` to `/docker-compose.yml`, NOT `/myapp/docker-compose.yml`.