| name | gse-deploy |
| description | Deploy the current project to a Hetzner server via Coolify. Adapts to the user's situation: from zero infrastructure (solo) to a pre-configured shared server (training). Triggered by /gse:deploy. |
GSE-One Deploy — Hetzner Deployment
Arguments: $ARGUMENTS
Options
| Flag / Sub-command | Description |
|---|
| (no args) | Deploy current project (detect situation, resume from last phase) |
| --status | Show deployment status (server, app URL, health) |
| --destroy | Tear down server and all data (Gate, confirm twice) |
| --redeploy | Force rebuild and redeploy (skip infrastructure phases) |
--registrar <name> | Show DNS instructions for a specific registrar (namecheap, gandi, ovh, cloudflare) without re-running earlier phases |
| --silent | Skip the Step -1 Orientation (for scripting/CI or experienced users) |
| --training-init | (Instructor) Generate .env.training for distribution to learners |
| --training-reap | (Instructor) Delete learner apps at end of course |
| --help | Show this command's usage summary |
Prerequisites
Before executing, read:
.gse/config.yaml — deploy section (provider, server type, datacenter)
.gse/deploy.json — infrastructure state (if exists)
.env — credentials and configuration (if exists)
.gse/profile.yaml — user profile (apply P9 Adaptive Communication to all output)
$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/agents/deploy-operator.md — adopt this role for the entire activity
$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/references/hetzner-infrastructure.md — pricing, sizing, Coolify API (consulted on demand)
$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/references/ssh-operations.md — SSH patterns and credential resolution (consulted on demand)
Workflow
Workflow structure note. /gse:deploy uses a two-level hierarchy reflecting its deployment lifecycle:
- Step -1 and Step 0 (Orientation, Situation Detection) — pre-flight checks before committing to a lifecycle.
- Phase 1 through Phase 6 (Setup → Provision → Secure → Install Coolify → Configure DNS → Deploy Application) — the main deployment lifecycle. Each Phase corresponds to an idempotent milestone tracked in
.gse/deploy.json under phases_completed.<phase_name>; a completed Phase is automatically skipped on re-run.
- Step N inside a Phase — sub-steps within a Phase (e.g., "Step 3 — Dockerfile template selection" inside Phase 6). This internal numbering resets for each Phase.
This two-level hierarchy is specific to /gse:deploy — other GSE-One activities use a flat Step sequence. The distinction reflects the idempotent-milestone nature of deployment.
Boundary note (per spec §1.4 — Design Philosophy). DEPLOY is an opinionated operations module (Hetzner + Coolify + Docker), deliberately outside the technology-neutral methodological core — the stack is a supported choice, not a methodology requirement.
Step -1 — Orientation (first-time users only)
Skip this step if ANY of the following is true:
- The
--silent flag was passed
.gse/deploy.json already exists
- Any of these env vars are set in
.env: HETZNER_API_TOKEN, SERVER_IP, COOLIFY_URL, COOLIFY_API_TOKEN, DEPLOY_DOMAIN, DEPLOY_USER
Otherwise (first-time user), display:
Welcome to /gse:deploy. This command provisions infrastructure and deploys your project. First, let me understand your situation:
(1) Solo — Deploy my own project to my own Hetzner server. (~1h first time, ~8.49 EUR/month ongoing. You'll need a Hetzner account and optionally a domain.)
(2) Instructor — Prepare a shared server for a training session. (~1h setup, then distribute a .env.training file to learners. Reap at course end.)
(3) Learner — My instructor sent me a .env.training file to use. (~5 min. You only deploy your project, no server setup.)
(4) Skip — I know what I'm doing, proceed to detection.
Which are you? [1/2/3/4]
Wait for the user's answer, then route:
(1) Solo — Persist role and brief:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-role solo
Say: "You'll go through 6 phases: installing the hcloud CLI, creating a Hetzner server, hardening it, installing Coolify, configuring DNS/SSL, and deploying your project. I'll guide you at each step. Type 'ready' to begin."
Wait for confirmation → proceed to Step 0.
(2) Instructor — Persist role and brief:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-role instructor
Say: "I'll walk you through the full 6-phase setup (same as Solo mode), then help you generate a .env.training file with --training-init to distribute to your learners. Heads-up: the token in that file gives learners write access to ALL apps on the shared server — we'll use a course-dedicated token you revoke at the end. If a learner's app is damaged or deleted mid-course (the shared token makes that possible), recovery is built in: the learner just re-runs /gse:deploy — the app is recreated from their GitHub repo in ~5 minutes. At the end of the course, run /gse:deploy --training-reap --all to clean up all learner apps (then revoke the token). Ready to start the server setup?"
Wait for confirmation → proceed to Step 0.
(3) Learner — Three precondition checks. Do NOT ask the learner — each one is deterministically verifiable in under a second; run the checks yourself and only surface the remediation message when a check fails (self-reported y/n answers are a classroom failure amplifier: a learner answers "yes" in good faith, is wrong, and fails 10 minutes later with a cryptic error):
- Check
.env exists: test -f .env
- If missing: "I can't find a
.env file. Copy the .env.training file your instructor sent you into your project directory as .env (cp .env.training .env). Then re-run /gse:deploy." → Exit.
- Check
DEPLOY_USER is set and is not the placeholder: grep -E '^DEPLOY_USER=' .env must match and must NOT be the learnerXX placeholder written by --training-init.
- If unset/placeholder: "Open
.env in your editor, set DEPLOY_USER=<your-learner-id>, save, and re-run /gse:deploy." → Exit.
- Check the project has a GitHub remote:
git remote -v shows an origin pointing at github.com (Coolify clones from GitHub — an unpushed project fails at deploy time). Optionally confirm the branch is pushed (git status -sb shows no ahead marker).
- If no remote: "Push it first: create a repo (
gh repo create or the GitHub UI), then git push -u origin <branch>. A public repo is simplest for the course; for a private repo, follow the GitHub App guide (cat "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/references/private-repo-github-app-setup.md", section B) BEFORE deploying. Then re-run /gse:deploy." → Exit.
On success, state what was verified in one line — e.g. "Checked: .env found with DEPLOY_USER=alice; GitHub remote origin present." — so the learner sees the preflight passed.
4. Persist role:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-role learner
- Say: "I'll detect your instructor's shared server from the training config and deploy your project at
{project-name}-{DEPLOY_USER}.{DEPLOY_DOMAIN}. Expected duration: ~5 minutes."
- Proceed to Step 0 (will detect app-only mode → Phase 6).
(4) Skip — Meta-action (not a role value): no record-role call; no user_role persisted in deploy.json. Proceed directly to Step 0. The 3 role values stored in user_role are solo / instructor / learner only — see design §5.18 State schema for the enum.
Step 0 — Situation Detection
Invoke the deploy tool:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" detect
This returns a JSON object with starting_phase, mode (full | partial | app-only | training), the presence map of .env variables, and the phases_completed map. Use the returned starting_phase to skip already-completed phases (idempotence is handled by the tool, not by this skill).
.env variables (all optional) — for reference:
| Variable | Purpose | Level |
|---|
HETZNER_API_TOKEN | Hetzner Cloud API access | Infrastructure |
SERVER_IP | Pre-existing server IP address | Server |
SSH_USER | SSH username (default: deploy) | Server |
SSH_KEY | Path to SSH private key | Server |
COOLIFY_URL | Coolify dashboard URL | Coolify |
COOLIFY_API_TOKEN | Coolify API access | Coolify |
DEPLOY_DOMAIN | Base domain for subdomains | Domain |
DEPLOY_USER | User identity for subdomain prefix (training mode) | Identity |
The detection logic (which variables map to which starting phase) is documented in gse-one-implementation-design.md §5.18 — /gse:deploy — Application Deployment Activity ("Situation detection" table) and authoritatively applied by the detect subcommand. Trust its output — do not re-derive the mapping from the table above.
Display the detected situation to the user:
- Full mode: "No deployment configuration found. I'll guide you through the complete setup."
- Partial: "I found a server at {IP}. Starting from there."
- App-only: "Deployment infrastructure is ready ({COOLIFY_URL}). I'll deploy your application."
- Training: "Training mode detected. I'll deploy your project on the shared server. The full URL will be shown after Phase 6 (typically
{project-name}-{DEPLOY_USER}.{DEPLOY_DOMAIN})."
Phase 1 — Setup (skip if HETZNER_API_TOKEN in .env)
Purpose: Install CLI tools, obtain credentials, save configuration.
-
Check hcloud CLI
- Run
hcloud version
- If not found, install:
- Verify:
hcloud version
-
Obtain Hetzner API token
- Guide the user step by step:
"Go to https://console.hetzner.cloud → select or create a project → Security → API Tokens → Generate API Token (Read & Write). Copy the token — it is shown only once."
- Ask the user to paste the token
- Never display the token back in chat
-
Domain name
- Ask: "What domain will you use for deployment? (e.g., my-project.com)"
-
Save credentials
-
Verify hcloud access
- Run
hcloud server list to confirm the token works
- If it fails: ask user to verify the token
-
Persist completion
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" init-state
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-phase setup
Phase 2 — Provision (skip if SERVER_IP in .env or deploy.json has phases_completed.provision)
Purpose: Create a Hetzner server with firewall.
-
Read configuration
- Server type:
config.yaml → deploy.server_type (default: cax21)
- Datacenter:
config.yaml → deploy.datacenter (default: fsn1)
- Server name:
gse-<project-name> (sanitized, lowercase, hyphens only)
-
Check for existing server
hcloud server list -o columns=name,ipv4
- If a server with this name exists: skip provision, record IP
-
Gate decision: server creation
- Display cost and configuration:
"Creating a Hetzner server:
- Type: CAX21 (4 vCPU ARM, 8 GB RAM, 80 GB SSD)
- Location: Falkenstein (fsn1)
- Monthly cost: ~8.49 EUR
Proceed?"
- Wait for explicit user confirmation
-
Generate SSH key (if ~/.ssh/gse-deploy does not exist)
ssh-keygen -t ed25519 -f ~/.ssh/gse-deploy -N ""
- Upload:
hcloud ssh-key create --name gse-deploy --public-key-from-file ~/.ssh/gse-deploy.pub
-
Create server
hcloud server create --name <name> --type <type> --location <datacenter> --image ubuntu-24.04 --ssh-key gse-deploy
- Record IP address from output
-
Create firewall
hcloud firewall create --name gse-fw-<name>
- Add rules: allow TCP 22 (SSH), 80 (HTTP), 443 (HTTPS), 8000 (Coolify setup, temporary)
- Apply to server:
hcloud firewall apply-to-resource gse-fw-<name> --type server --server <name>
-
Verify SSH access
ssh -i ~/.ssh/gse-deploy -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 root@<IP> "echo ok"
- Retry up to 3 times with 15s wait between attempts (per
plugin/references/ssh-operations.md — the canonical SSH pattern source)
-
Persist completion
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-server \
--name <name> --ipv4 <IP> --id <id> --type <type> --datacenter <dc>
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" env-set SERVER_IP <IP>
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" env-set SSH_USER root
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" env-set SSH_KEY ~/.ssh/gse-deploy
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-phase provision
Phase 3 — Secure (skip if deploy.json has phases_completed.secure)
Purpose: Harden the server — non-root user, SSH hardening, firewall, intrusion detection.
-
Create deploy user
-
ssh root@<IP> "adduser --disabled-password --gecos '' deploy && \
usermod -aG sudo deploy && \
echo 'deploy ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/deploy"
- Copy SSH key:
ssh root@<IP> "mkdir -p /home/deploy/.ssh && \
cp /root/.ssh/authorized_keys /home/deploy/.ssh/ && \
chown -R deploy:deploy /home/deploy/.ssh && \
chmod 700 /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys"
-
Verify deploy user SSH
ssh -i ~/.ssh/gse-deploy -o ConnectTimeout=10 deploy@<IP> "sudo echo ok"
- CRITICAL: do not proceed if this fails — root access will be disabled next
-
Harden SSH
- Edit
/etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
ssh deploy@<IP> "sudo systemctl restart sshd"
-
Install UFW firewall
-
Install ufw-docker (prevents Docker from bypassing UFW rules)
-
Install fail2ban
-
Automatic security updates
-
Persist completion
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" env-set SSH_USER deploy
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-phase secure
Phase 4 — Install Coolify (skip if COOLIFY_URL in .env or deploy.json has phases_completed.coolify)
Purpose: Install Coolify v4 (self-hosted PaaS with Traefik reverse proxy).
-
Install Coolify
ssh deploy@<IP> "curl -fsSL https://cdn.coollabs.io/coolify/install.sh | sudo bash"
-
Wait for readiness
- Poll
http://<IP>:8000 every 15 seconds, timeout 5 minutes
- Show progress: "Waiting for Coolify to start..."
-
Guide browser setup (detailed)
Walk the user through the Coolify onboarding. Tell them verbatim:
Coolify is ready. Open http://<IP>:8000 in your browser.
3a. Create your admin account:
- Fill in: Email, Name, Password (min 8 chars), Confirm password
- Click Register. You are now logged in.
3b. Initial setup wizard:
- Coolify displays a "Welcome" screen. Click Let's go! or Skip through optional settings (root user email, instance name — you can change these later in Settings).
3c. Create an API token:
- In the left sidebar, click your profile icon (bottom left) → Keys & Tokens → API Tokens tab.
- Click Create New Token.
- Name it:
gse-one
- Permissions: check Read, Write, and Deploy (or the "Full Access" preset if available).
- Click Continue. The token is displayed only once — copy it now.
3d. Paste the token here.
Then persist:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" env-set COOLIFY_URL http://<IP>:8000
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" env-set COOLIFY_API_TOKEN <token>
If the UI has changed (Coolify iterates fast), adapt the step numbering to what the user describes. The invariants are: create account → find "API tokens" or equivalent → create token with full-access permissions → copy once. If a step path differs significantly, please consider submitting a PR to update this section (see README → Deployment → Maintaining upstream compatibility).
-
Verify containers
ssh deploy@<IP> "sudo docker ps --format 'table {{.Names}}\t{{.Status}}'"
- Check: coolify, traefik, postgres containers running
-
Persist completion
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-coolify --url http://<IP>:8000
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-phase coolify
Phase 5 — Configure DNS (skip if deploy.json has phases_completed.dns)
Purpose: Point the domain to the server and enable SSL.
-
Guide DNS configuration
- Tell the user (verbatim):
Add these DNS records at your domain registrar:
| Type | Name | Value | TTL |
|---|
| A | @ | <server-IP> | 300 |
| A | * | <server-IP> | 300 |
The wildcard * enables all subdomains (project-a.domain.com, project-b.domain.com, ...) to point to this server automatically — essential for the multi-app pattern.
- Ask the user which registrar they use; if one of the four below, display the registrar-specific subsection. Otherwise, provide the generic records and invite them to follow their registrar's documentation (you can also run
/gse:deploy --registrar <name> later to re-display the steps).
1a. Namecheap
- Log in at https://www.namecheap.com → Domain List (top menu).
- Next to your domain, click Manage.
- Open the Advanced DNS tab.
- Under Host Records, click Add New Record:
- Type:
A Record, Host: @, Value: <server-IP>, TTL: Automatic.
- Click Add New Record again:
- Type:
A Record, Host: *, Value: <server-IP>, TTL: Automatic.
- Click the green check next to each to save. Changes propagate in 5–30 minutes.
1b. Gandi
- Log in at https://admin.gandi.net → Domaines (or Domains).
- Click your domain → DNS Records (or Enregistrements DNS).
- Click Add record. Type
A, Name @, TTL 300, Value <server-IP>. Save.
- Click Add record again. Type
A, Name *, TTL 300, Value <server-IP>. Save.
1c. OVH
- Log in at https://www.ovh.com/manager/ → Web Cloud → Domains (or Noms de domaine).
- Click your domain → DNS Zone (or Zone DNS).
- Click Add an entry:
- Type:
A, Sub-domain: (leave empty for @), Target: <server-IP>, TTL: 300.
- Click Add an entry again:
- Type:
A, Sub-domain: *, Target: <server-IP>, TTL: 300.
1d. Cloudflare (DNS-only for now, CDN opt-in later)
- Log in at https://dash.cloudflare.com → click your domain.
- Open the DNS → Records page.
- Click Add record:
- Type:
A, Name: @, IPv4 address: <server-IP>, Proxy status: DNS only (grey cloud), TTL: Auto.
- Click Add record again:
- Type:
A, Name: *, IPv4 address: <server-IP>, Proxy status: DNS only (grey cloud), TTL: Auto.
- Important: keep the proxy OFF at this stage. Let's Encrypt needs direct DNS resolution to issue the certificate. After SSL is active and Coolify dashboard works, the next step (CDN proposal) offers to enable proxy + CDN.
-
Verify DNS propagation
Invoke the tool with a timeout of 10 minutes:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" wait-dns \
--domain <domain> --expected-ip <server-IP> --timeout 600
The tool polls dig (system resolver + @8.8.8.8 fallback every 15s). On resolved, proceed. On timeout, it returns a hint — display it to the user and exit the skill without marking the phase complete (so the user can re-run /gse:deploy once propagation completes).
-
Configure Coolify dashboard domain
Use the Coolify API to set the dashboard FQDN to coolify.<domain>:
curl -X PATCH "http://<IP>:8000/api/v1/settings/general" \
-H "Authorization: Bearer $COOLIFY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"instance_fqdn":"https://coolify.<domain>"}'
Then update the env:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" env-set COOLIFY_URL https://coolify.<domain>
-
Verify SSL (Let's Encrypt via Traefik)
Coolify+Traefik requests a Let's Encrypt certificate automatically on first HTTPS request. Poll until it succeeds (max 2 minutes):
for i in $(seq 1 8); do
if curl -sI "https://coolify.<domain>" 2>/dev/null | grep -q "HTTP.*200\|HTTP.*301\|HTTP.*302"; then
echo "SSL OK"; break
fi
sleep 15
done
If SSL fails, check DNS propagation first (dig coolify.<domain> must return <server-IP>).
-
Close temporary port 8000
ssh deploy@<IP> "sudo ufw delete allow 8000/tcp"
hcloud firewall delete-rule gse-fw-<name> --direction in --protocol tcp --port 8000
-
Persist domain completion
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-domain --base <domain> --registrar <namecheap|gandi|ovh|cloudflare|manual>
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-phase dns
-
Propose Cloudflare CDN + DDoS + WAF (recommended for production)
Ask the user (Inform tier):
Your domain is live. Would you like to add Cloudflare CDN & DDoS protection? (free plan, ~10 minutes, no downtime)
Benefits:
- Free CDN (faster page loads worldwide)
- DDoS protection (layer 3/4/7)
- Bot & AI-scraper detection/blocking
- Web Application Firewall (WAF)
- Rate limiting per IP
- Analytics & threat dashboard
Without it, the server has only: Hetzner network-level DDoS (L3/L4), UFW firewall, fail2ban (SSH only).
If the user declines: record --provider none and exit this step:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-cdn --provider none
If the user accepts: walk them through:
7a. Create a free Cloudflare account at https://dash.cloudflare.com (skip if they have one).
7b. Click Add a site → enter the domain → Free plan.
7c. Cloudflare displays nameservers (e.g., nina.ns.cloudflare.com, rob.ns.cloudflare.com). Copy both.
7d. Change nameservers at the original registrar:
- Namecheap: Domain List → Manage → Nameservers → select Custom DNS → paste Cloudflare NS1 and NS2 → save.
- Gandi: Domain → Nameservers → Change → paste NS1 and NS2 → save.
- OVH: Domain → DNS servers (or Serveurs DNS) → Modify → paste NS1 and NS2 → save.
- (Cloudflare-as-registrar: already handled automatically.)
7e. Wait for nameserver activation (minutes to 24h; Cloudflare sends an email when active). During this window, the site may be briefly unreachable — warn the user.
7f. Once active, in Cloudflare DNS → Records:
- Change A
@ → Proxy status: Proxied (orange cloud).
- Change A
* → Proxy status: Proxied (orange cloud).
- Add A
coolify → <server-IP> → Proxy status: DNS only (grey cloud, because the admin panel uses WebSockets that Cloudflare proxy can break).
7g. In SSL/TLS:
- Mode: Full (strict) (the server already has a valid Let's Encrypt cert).
- Always Use HTTPS: ON.
- Minimum TLS Version: 1.2.
7h. In Security → Bots:
- Bot Fight Mode: ON.
- AI Scrapers and Crawlers: Block.
7i. In Security → Settings:
- Security Level: Medium.
- Challenge Passage: 30 minutes.
7j. Record the CDN state:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" record-cdn --provider cloudflare --enabled --bot-protection
If Cloudflare UI has changed (Cloudflare redesigns periodically): adapt wording to what the user sees. Invariants: DNS with proxy orange cloud on apps + grey cloud on coolify; SSL Full (strict); Bot Fight Mode + AI Scrapers Block. If navigation diverges significantly, please submit a PR (see README → Deployment → Maintaining upstream compatibility).
Phase 6 — Deploy Application
Purpose: Deploy the current project as a Coolify application.
-
Determine subdomain
Invoke the deploy tool:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" subdomain \
--project "$PWD" \
--user "$(python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" env-get DEPLOY_USER | python3 -c 'import json,sys; print(json.load(sys.stdin).get(\"value\") or \"\")')" \
--domain "$(python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" env-get DEPLOY_DOMAIN | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"value\"])')"
The tool applies the full P1 rule (sanitization, training vs solo pattern, RFC 1035 length checks). If it returns {"status": "error", ...}, abort and report the error to the user. On success ("status": "ok"), it returns {project_name, deploy_user, label, subdomain, url}.
-
Preflight checks — delegate to the tool for deterministic, typed checks:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" preflight
The tool detects the project type (streamlit | python | node | static | custom), derives the default port, and runs a structured list of checks: git state (repo, commits, remote, github.com hint, working tree), entry points per type (Streamlit/Python/Node), Streamlit reverse-proxy config (enableCORS=false, enableXsrfProtection=false), Node start script + Next.js build hint, static index.html, and Dockerfile ARG SOURCE_COMMIT. It returns JSON with type, port, overall (ok | warnings | errors), and the full checks list.
Handle the result:
overall == "errors" → abort the skill. Display each failed check (name, message, fix_hint). Ask the user to resolve and re-run /gse:deploy.
overall == "warnings" → list the warnings (Inform tier). Present a Gate: "Proceed despite these warnings?" (Typical: uncommitted changes, missing Streamlit CORS config, remote not on github.com, missing ARG SOURCE_COMMIT.) On accept → continue. On decline → exit, suggest the fixes.
overall == "ok" → proceed silently.
Capture the returned type and port — used in Step 3 (Dockerfile template selection) and Step 4 (deploy-app --type --port).
-
Generate Dockerfile and .dockerignore (if not present in project root)
- Use the
type returned by the preflight tool in Step 2 (do not re-detect — the tool is authoritative). If config.yaml → deploy.app_type is set to an explicit value (not auto), that value overrides the detected type.
- Map the type to a template:
streamlit → $([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/templates/Dockerfile.streamlit
python → $([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/templates/Dockerfile.python
node → $([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/templates/Dockerfile.node
static → $([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/templates/Dockerfile.static
- Copy the selected template to
./Dockerfile in the project, and copy $([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/templates/.dockerignore to ./.dockerignore if absent.
- Show the generated Dockerfile to the user for approval (Inform tier), highlighting any CMD/ENTRYPOINT line the user may need to customize (notably for Python: FastAPI/Flask/script comment block; for Node: optional
RUN npm run build).
- Commit both files if the user approves.
-
Deploy the application (Coolify project → environment → app → build → health check → state)
This step consolidates what were previously five sub-steps (create project, create app, trigger build, health check, update state). The tool does all of it in one call:
Concurrency note — run deploy-app calls sequentially, never in parallel. When a user deploys multiple apps under the same DEPLOY_USER (e.g., a WEB and an API app for the same learner), each call invokes ensure_project on the shared Coolify project gse-{DEPLOY_USER}. The Coolify API exposes no atomic get-or-create for projects, so two concurrent deploy-app invocations can both observe "project does not exist" and both issue a POST /projects — producing two identically-named duplicate projects, both empty if subsequent app creation fails (e.g., the 422-private-repo branch documented below). Always instruct the user to wait for the first deploy-app to return (health check resolved, URL announced) before launching the next. If duplicates already exist, point the user to docs/deploy/coolify-cli-cheatsheet.md for the curl-based cleanup recipe.
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" deploy-app \
--name "<label-from-subdomain-step>" \
--project-name "<pre-sanitization-dir-name>" \
--deploy-user "<DEPLOY_USER or empty>" \
--subdomain "<fqdn-from-subdomain-step>" \
--github-repo "<user/repo>" \
--branch "<current-branch>" \
--type "<streamlit|python|node|static|custom>" \
--port <N> \
--health-timeout <config.yaml → deploy.health_check_timeout, default 120>
What the tool does:
- Ensures a Coolify project (
gse-{DEPLOY_USER} in training, gse in solo) exists.
- Ensures a
production environment exists within that project.
- Looks up
applications[] in .gse/deploy.json by name. If a matching entry with coolify.app_uuid exists → triggers a redeploy (GET /api/v1/deploy?uuid=...&force=true). Otherwise, creates the app via POST /api/v1/applications/public — or POST /api/v1/applications/private-github-app when COOLIFY_GITHUB_APP_UUID is set in .env — resolving and sending server_uuid (from .env SERVER_UUID, else the sole server returned by GET /api/v1/servers, persisted back to .env), then triggers the initial deploy.
- Polls the health endpoint (
/_stcore/health for Streamlit, / for others) for --health-timeout seconds (the skill passes config.yaml → deploy.health_check_timeout, default 120 — the tool itself never reads config.yaml).
- Records the application entry in
.gse/deploy.json → applications[] with all fields (identification, source, runtime, Coolify UUIDs, resources, timestamps, status).
Return JSON: {"status": "healthy|unhealthy|timeout|error", "url": "...", "app_uuid": "...", "created": true|false, "http_code": N}.
Error branch — Coolify 422 on private GitHub repo. If the tool returns {"status": "error", "http_status": 422, ...} and the user's repo is private (check via gh repo view <user/repo> --json isPrivate or ask the user), Coolify cannot clone via its public-repo endpoint. Do NOT suggest making the repo public — that is a privacy regression, not a fix. Instead, display this guidance verbatim (adapt tone to the detected user_role):
"Your repo is private, so Coolify needs an authenticated source to clone it. In a training context, your trainer has typically pre-configured a Coolify 'GitHub App' source for this. Ask your trainer for two values: (a) the GitHub App install link (of the form https://github.com/apps/<app-slug>/installations/new) and (b) the Coolify Source UUID (a short alphanumeric string). Once you have both: (1) click the install link and install the App on your GitHub account, selecting only the repo you want to deploy; (2) run deploy.py env-set COOLIFY_GITHUB_APP_UUID <uuid>; (3) re-run this deploy-app command. Full procedure: cat "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/references/private-repo-github-app-setup.md" (section B)."
Solo users (no trainer) can create their own Coolify GitHub App source via Coolify UI → Sources → GitHub App, then follow the same install + env-set flow. See the App-owner section (A) of cat "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/references/private-repo-github-app-setup.md" — in solo mode you are your own App owner.
Note — since v0.64.0, deploy.py auto-routes to the Coolify /applications/private-github-app endpoint when COOLIFY_GITHUB_APP_UUID is set. After env-set, re-running deploy-app completes end-to-end (the tool also resolves and sends the required server_uuid). If the retry still fails, verify the GitHub App is actually installed on the user's GitHub account with access to the repo (see cat "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/references/private-repo-github-app-setup.md", section B troubleshooting).
-
Report
- If
status == "healthy": "Your project is live at: https://{subdomain}"
- If
status is unhealthy or timeout: report failure, suggest checking the Coolify dashboard at {COOLIFY_URL}.
- If
status == "error": show the Coolify error message and suggest corrective action.
- Solo mode: append "Server: {IP} ({server-type}), monthly cost: ~8.49 EUR".
- Training mode: append "Deployed on shared training server."
-
Verify deployed version matches repo HEAD (Inform-tier, pedagogical)
Healthy ≠ correct version. A successful Coolify deploy can still serve a stale build (caching, rollback after failed redeploy, partial deploy). Compare what is live against what the local repo declared:
-
Read the local source of truth: git rev-parse --short HEAD and, if a release tag was emitted by /gse:deliver, the latest v* tag on main.
-
Probe the live app for its version. Try in order:
curl -fsS https://{subdomain}/version (if the project exposes a /version endpoint)
curl -fsS https://{subdomain}/_stcore/health and inspect any embedded version string
- Fetch the HTML root and grep for a version marker (footer, meta tag, JS bundle hash)
-
Display the result:
DEPLOYED VERSION CHECK
Repo HEAD: {short_sha} (tag: {v*-or-untagged})
Live app: {detected_string-or-"unknown"}
Match: {OK | MISMATCH | UNKNOWN}
-
Interpretation guidance:
- OK → deployment confirmed end-to-end. Proceed.
- MISMATCH → suggest one of: (a)
/gse:deploy --redeploy to force a fresh build, (b) inspect Coolify deployment logs for a silent rollback (e.g., healthcheck failure → previous container restored — observed in real sessions), (c) hard-refresh in browser (cache).
- UNKNOWN → no version surface in the app. Suggest adding a minimal
/version endpoint or a footer string as a future improvement. Do not block — many projects do not expose this, and the check then carries no signal.
This step is purely Inform — it does not modify state, does not call other activities, and does not block on a MISMATCH. Rationale: in observed sessions, users assumed a healthy deploy meant the new code was live, only to discover at runtime that the previous version was still being served. A 30-second cross-check at the end of /gse:deploy closes that gap without adding methodological weight.
--status Option
When invoked with --status:
-
Invoke:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" state --human
This prints the full state (phases, server, Coolify, domain, applications table).
-
For each application listed, run a live health check:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" app-status <app-name>
and display the current status to the user.
-
If no .gse/deploy.json exists (the tool will return an empty state): "No deployment found. Run /gse:deploy to deploy."
--destroy Option
When invoked with --destroy:
-
Dry-run first — always surface the impact before the Gates:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" destroy --dry-run
Display the result to the user:
Impact of /gse:deploy --destroy:
- Applications to delete: {count} ({comma-separated list})
- Coolify projects to delete: {count} ({comma-separated list})
- Hetzner server to delete: {server-name}
- Firewall to delete: {firewall-name}
- Estimated cost savings: {cost-hint}
-
Gate 1 — first confirmation (generic):
"This is a destructive operation that cannot be undone. It will stop billing for the Hetzner server but will NOT touch DNS records at your registrar (you may want to remove them manually afterwards). Proceed?"
Wait for explicit user confirmation (yes/no). If no: exit without change.
-
Gate 2 — retype the server name:
"To confirm, type the server name exactly: {server-name}"
Wait for the user to type the literal name. If mismatch: abort with "Name mismatch. Destroy cancelled."
-
Execute destroy with the typed name as confirmation:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" destroy --confirm <typed-name>
-
Handle the result:
- If
status == "ok": all deletions succeeded. State file is cleared. Continue to step 6.
- If
status == "partial": some deletions failed (see errors array). State is preserved for retry — the user can re-run /gse:deploy --destroy later once the issue is resolved. Surface the errors list verbatim so the user can diagnose (e.g., hcloud token expired, network issue, Coolify down). Skip step 6 (keep .env intact for retry).
-
Clean up .env variables (only on status == "ok"):
for key in SERVER_IP SERVER_UUID SSH_USER SSH_KEY COOLIFY_URL COOLIFY_API_TOKEN COOLIFY_GITHUB_APP_UUID; do
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" env-delete "$key"
done
-
Post-destroy report + warnings (only on status == "ok"):
Deployment configuration cleared. {cost_savings}.
Remaining external resources to consider:
- DNS records at your registrar (
A @ and A *) still point to a dead IP — remove or repurpose them.
- Cloudflare CDN (if you set it up): consider removing the zone or reverting nameservers to the registrar's defaults.
- Let's Encrypt certificates were automatically revoked when Coolify shut down — no action needed.
- Hetzner SSH key (
gse-deploy) is still registered in your Hetzner project if you want to reuse it.
--redeploy Option
When invoked with --redeploy:
- Verify that
deploy.json → phases_completed.dns is set (infrastructure is ready). If not, abort with "Infrastructure not ready. Run /gse:deploy first to complete Phases 1-5."
- Skip directly to Phase 6 (subdomain computation +
deploy-app + report). The deploy-app subcommand auto-detects the existing application entry by name and triggers a forced rebuild via GET /api/v1/deploy?uuid=...&force=true.
- Return the same JSON contract as a normal deploy:
{status, url, app_uuid, created: false, http_code}.
--registrar Option
When invoked with --registrar <name> (where <name> is namecheap, gandi, ovh, or cloudflare):
- Read
.gse/deploy.json → server.ipv4. If missing: "No server IP recorded. Run /gse:deploy to provision first."
- Display only the corresponding registrar subsection from Phase 5 Step 1 (1a/1b/1c/1d) with the server IP substituted.
- Remind the user they can run
/gse:deploy (no args) afterwards to continue from the detected phase.
Useful when the user skipped the full flow, already has infrastructure, and just needs the registrar-specific DNS instructions.
--training-init Option (Instructor-only)
When invoked with --training-init:
- Verify
phases_completed.dns is set (the instructor has completed Phases 1-5). If not, abort with "Training-init requires completed infrastructure. Finish /gse:deploy first."
- Invoke:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" training-init [--output .env.training]
- The tool generates
.env.training containing COOLIFY_URL, COOLIFY_API_TOKEN, DEPLOY_DOMAIN, and a DEPLOY_USER=learnerXX placeholder — plus SERVER_UUID and COOLIFY_GITHUB_APP_UUID when present in the instructor's .env (so the single handout also covers recent Coolify versions and learners with private repos; remind the instructor to set both before generating if the cohort needs them). It excludes HETZNER_API_TOKEN, SERVER_IP, SSH keys. A security warning is embedded as a comment.
- Mandatory token step — before distributing anything: instruct the instructor to generate a dedicated Coolify API token for the course (Coolify UI → Keys & Tokens) and re-run
--training-init with it in .env if the current token is their personal one. State the risk explicitly: "The token embedded in .env.training gives every learner WRITE access to ALL applications on the shared Coolify server — any learner can modify or delete other learners' apps (or yours). Use a course-dedicated token, and revoke it in the Coolify UI as soon as the course ends (--training-reap cleans the apps, not the token)."
- Display the output path to the instructor and remind them:
"Distribute .env.training to your learners. They copy it to their project as .env and set DEPLOY_USER."
"Mid-course incident recipe: if a learner's app is damaged or deleted (the shared token allows it), the learner just re-runs
/gse:deploy — the app is recreated from their GitHub repo in ~5 minutes. No instructor intervention needed."
"Pre-course tip: have learners install GSE-One with a pinned version — GSE_VERSION=vX.Y.Z curl … | sh — so the whole cohort runs the same version AND you avoid the unauthenticated GitHub API rate limit (~60 requests/hour/IP) when 20 learners install behind one classroom NAT (see README → Troubleshooting → GitHub API rate limit)."
--training-reap Option (Instructor-only)
When invoked with --training-reap:
- Prompt for scope (Gate):
"Reap which? (1) a single learner, (2) all gse- projects, (3) cancel"*
- If option 1: ask for the learner name, then run a dry-run first:
python3 "$([ -s .gse/registry ] && cat .gse/registry || cat ~/.gse-one)/tools/deploy.py" training-reap --user <name> --dry-run
Display the list of projects that would be deleted. If the instructor confirms (Gate), re-invoke with --confirm <name> to actually delete.
- If option 2: run
training-reap --all --dry-run, display the list, confirm (Gate), then --all --confirm all.
- The tool deletes the selected Coolify projects via API and syncs
.gse/deploy.json → applications[] by removing entries whose deploy_user matches.
- Report the number of projects deleted and any errors (e.g., partial failures).
The gse solo project (instructor's own apps) is never touched by --training-reap.
--help Option
When invoked with --help:
Display a role-first narrative, then the Options table:
Who are you?
-
Solo user → Run /gse:deploy (no args). First run walks you through 6 phases (setup, provision, secure, Coolify, DNS, deploy). Expect ~1h first time, ~8.49 EUR/month ongoing.
-
Instructor → Run /gse:deploy once to set up a shared server. Then /gse:deploy --training-init to generate .env.training for distribution to learners. At course end, /gse:deploy --training-reap --all.
-
Learner → Copy the .env.training file your instructor sent into your project as .env, set DEPLOY_USER=<your-id>, then run /gse:deploy. Takes ~5 minutes.
All commands support --status (inspect state), --redeploy (force rebuild), --destroy (tear down, double Gate), --silent (skip orientation for scripting).
Then display the full Options table.
If config.yaml → deploy.app_type is set to a specific value (not auto), also mention which Dockerfile template would be used (e.g., "Current app_type: python → will use Dockerfile.python").