Skip to content

Instantly share code, notes, and snippets.

@nevyn
Created April 11, 2026 23:36
Show Gist options
  • Select an option

  • Save nevyn/f5d6d69b38fdb81a61a600def924e8b7 to your computer and use it in GitHub Desktop.

Select an option

Save nevyn/f5d6d69b38fdb81a61a600def924e8b7 to your computer and use it in GitHub Desktop.
coolify — a bash CLI wrapper for Coolify's REST API. Auth via ~/.coolify-token, aliases in ~/.coolify-cli-config. Tested against v4.0.0-beta.472. See https://nevyn.dev/blog/20260411-agent-cli-coolify/
#!/usr/bin/env bash
# coolify — a bash CLI wrapper for Coolify's REST API
#
# Why: the Coolify API has enough sharp edges (field name asymmetries, multiple
# create-app endpoints, JSON payload gymnastics) that wrapping it pays for itself
# after a handful of deployments. This script is what I use on my own Coolify
# instance, with Claude Code agents driving it via SSH.
#
# More context: https://nevyn.dev/blog/20260411-agent-cli-coolify/
#
# Setup:
# 1. Install on the same machine that runs Coolify (needed for exec/app-logs
# which reach through to `docker`; other commands work remote too).
# 2. Save as ~/bin/coolify and `chmod +x ~/bin/coolify`.
# 3. Put your API token in ~/.coolify-token (one line, no prefix).
# Generate one in the Coolify UI under "Keys & Tokens > API tokens".
# 4. On first run, the script creates ~/.coolify-cli-config with placeholder
# UUIDs. Edit it to match your setup:
# - project_uuid — from the URL when viewing your Coolify project
# - server_uuid — from Servers > your server > URL
# - github_app_uuid — only if you use the GitHub App integration
# Both `create-app` (for GitHub App) and `create-db` use these defaults.
#
# Requires: bash, curl, jq. (exec/app-logs also need docker on the same host.)
#
# License: MIT. No warranty. Tested against Coolify v4.0.0-beta.472.
set -euo pipefail
TOKEN=$(cat ~/.coolify-token)
CONFIG="${COOLIFY_CONFIG:-$HOME/.coolify-cli-config}"
BASE="${COOLIFY_API_BASE:-http://localhost:8000/api/v1}"
AUTH="Authorization: Bearer $TOKEN"
CT="Content-Type: application/json"
# Load config (ini-style: key=value)
load_config() {
if [[ ! -f "$CONFIG" ]]; then
cat > "$CONFIG" <<'DEFAULTS'
# Coolify CLI config — edit these with your own UUIDs.
# Find them in the Coolify UI URLs, e.g.
# https://coolify.example.com/project/<project_uuid>/environment/...
# https://coolify.example.com/server/<server_uuid>
# https://coolify.example.com/source/github/<github_app_uuid>
project_uuid=REPLACE_ME_PROJECT_UUID
server_uuid=REPLACE_ME_SERVER_UUID
github_app_uuid=REPLACE_ME_GITHUB_APP_UUID
# App aliases (name=uuid)
# Added automatically by 'coolify create-app', or manually with 'coolify alias'.
[apps]
DEFAULTS
echo "Created $CONFIG — edit it with your project/server/github-app UUIDs before running other commands." >&2
fi
}
get_config() {
local key="$1"
grep "^${key}=" "$CONFIG" | tail -1 | cut -d= -f2-
}
# Resolve app name to UUID via config file
resolve_uuid() {
local input="$1"
local uuid=$(grep "^${input}=" "$CONFIG" | tail -1 | cut -d= -f2-)
if [[ -n "$uuid" ]]; then
echo "$uuid"
else
echo "$input"
fi
}
# Find the running container name for an app UUID (requires docker locally)
resolve_container() {
local uuid="$1"
docker ps --format '{{.Names}}' | grep "^${uuid}" | head -1
}
# Register an app alias in the config file
register_app() {
local name="$1"
local uuid="$2"
if grep -q "^${name}=" "$CONFIG" 2>/dev/null; then
sed -i "/^${name}=/d" "$CONFIG"
fi
echo "${name}=${uuid}" >> "$CONFIG"
}
load_config
PROJECT_UUID=$(get_config project_uuid)
SERVER_UUID=$(get_config server_uuid)
GITHUB_APP_UUID=$(get_config github_app_uuid)
usage() {
local app_names=$(grep -A1000 '^\[apps\]' "$CONFIG" 2>/dev/null | tail -n +2 | grep '=' | cut -d= -f1 | tr '\n' ', ' | sed 's/,$//')
cat <<EOF
Usage: coolify <command> [args]
Querying:
apps List all applications
status <app> Show app status and config
env <app> Show environment variables
dbs List all databases
deploy-status <deployment-uuid> Check deployment status
app-logs <app> [-n LINES] Show runtime container logs (default 100)
Deploying:
deploy <app> Trigger deployment, returns deployment ID
tail <app> Deploy and follow logs until done
logs <deployment-uuid> Show deployment logs
restart <app> Restart without rebuilding
Runtime:
exec <app> <command...> Run a command in the app container
Creating:
create-app <owner/repo> [options] Create app from GitHub repo
Options: --name <name> --branch <branch> --base-dir <dir>
--port <port> --project <uuid>
create-db <name> <user> <pass> [--project <uuid>]
Create Postgres database and start it
set-domain <app> <domain> Set app domain (Traefik handles TLS)
Configuring:
set-env <app> <KEY> <VALUE> Add/update an env var (VALUE='-' reads stdin)
set-env-file <app> <KEY> <file> Set env var from file contents
del-env <app> <KEY> Delete an environment variable
set-health <app> <path> Set health check path (e.g. /api/health)
alias <name> <uuid> Register an app name alias
Storage (requires Coolify beta.470+):
list-storage <app> List persistent and file storages
add-volume <app> <name> <mount> Add a Docker volume mount
(e.g. add-volume myapp data /app/data)
add-dir-mount <app> <host> <mount>
Bind-mount a host directory into the container
(e.g. add-dir-mount myapp /var/data /var/www/data)
del-storage <app> <storage-uuid> Delete a storage by UUID
Low-level:
api <method> <path> [json-body] Raw API call with auth
Known apps: ${app_names:-none}
Config: $CONFIG
EOF
exit 1
}
# --- Query commands ---
cmd_apps() {
curl -s -H "$AUTH" "$BASE/applications" | jq -r '.[] | "\(.uuid)\t\(.name)\t\(.status)"'
}
cmd_status() {
local uuid=$(resolve_uuid "$1")
curl -s -H "$AUTH" "$BASE/applications/$uuid" | jq '{name, status, fqdn, git_repository, git_branch, health_check_path, health_check_enabled, ports_exposes, updated_at}'
}
cmd_env() {
local uuid=$(resolve_uuid "$1")
curl -s -H "$AUTH" "$BASE/applications/$uuid/envs" | jq -r '.[] | "\(.key)=\(.value)"'
}
cmd_dbs() {
curl -s -H "$AUTH" "$BASE/databases" | jq -r '.[] | "\(.uuid)\t\(.name)\t\(.type)\t\(.status)"'
}
cmd_deploy_status() {
local dep_uuid="$1"
curl -s -H "$AUTH" "$BASE/deployments/$dep_uuid" | jq -r '.status'
}
cmd_app_logs() {
local uuid=$(resolve_uuid "$1")
shift
local lines=100
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--lines) lines="$2"; shift 2 ;;
*) shift ;;
esac
done
local container=$(resolve_container "$uuid")
if [[ -z "$container" ]]; then
echo "Error: no running container found for $uuid"
exit 1
fi
docker logs "$container" --tail "$lines" 2>&1
}
# --- Deploy commands ---
cmd_deploy() {
local uuid=$(resolve_uuid "$1")
curl -s -H "$AUTH" "$BASE/deploy?uuid=$uuid" | jq -r '.deployments[0].deployment_uuid'
}
cmd_logs() {
local dep_uuid="$1"
curl -s -H "$AUTH" "$BASE/deployments/$dep_uuid" | jq -r '.status as $s | .logs | map(.output) | join("\n") | . + "\n\n--- STATUS: \($s) ---"'
}
cmd_tail() {
local uuid=$(resolve_uuid "$1")
local dep_uuid=$(cmd_deploy "$uuid")
echo "Deployment started: $dep_uuid"
while true; do
local status=$(curl -s -H "$AUTH" "$BASE/deployments/$dep_uuid" | jq -r '.status')
echo " Status: $status"
if [[ "$status" == "finished" || "$status" == "failed" || "$status" == "cancelled" ]]; then
cmd_logs "$dep_uuid"
exit $([[ "$status" == "finished" ]] && echo 0 || echo 1)
fi
sleep 10
done
}
cmd_restart() {
local uuid=$(resolve_uuid "$1")
curl -s -X POST -H "$AUTH" "$BASE/applications/$uuid/restart" | jq .
}
# --- Runtime commands (require docker locally) ---
cmd_exec() {
local uuid=$(resolve_uuid "$1")
shift
local container=$(resolve_container "$uuid")
if [[ -z "$container" ]]; then
echo "Error: no running container found for $uuid"
exit 1
fi
local flags=""
[[ -t 0 ]] && flags="-it"
docker exec $flags "$container" "$@"
}
# --- Create commands ---
cmd_create_app() {
local repo="$1"
shift
# Defaults
local name=$(basename "$repo")
local branch="main"
local base_dir="/"
local port="3000"
local project="$PROJECT_UUID"
# Parse options
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--branch) branch="$2"; shift 2 ;;
--base-dir) base_dir="$2"; shift 2 ;;
--port) port="$2"; shift 2 ;;
--project) project="$2"; shift 2 ;;
*) name="$1"; shift ;; # positional name for backwards compat
esac
done
local payload=$(jq -n \
--arg project "$project" \
--arg server "$SERVER_UUID" \
--arg ghapp "$GITHUB_APP_UUID" \
--arg repo "$repo" \
--arg name "$name" \
--arg branch "$branch" \
--arg base_dir "$base_dir" \
--arg port "$port" \
'{
project_uuid: $project,
environment_name: "production",
server_uuid: $server,
name: $name,
git_repository: $repo,
git_branch: $branch,
build_pack: "dockerfile",
base_directory: $base_dir,
ports_exposes: $port,
github_app_uuid: $ghapp,
instant_deploy: false
}')
local result=$(curl -s -X POST -H "$AUTH" -H "$CT" -d "$payload" "$BASE/applications/private-github-app")
echo "$result" | jq .
local uuid=$(echo "$result" | jq -r '.uuid // empty')
if [[ -n "$uuid" ]]; then
local alias=$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
register_app "$alias" "$uuid"
echo "Registered alias: $alias=$uuid"
fi
}
cmd_create_db() {
local name="$1"
local user="$2"
local pass="$3"
shift 3
local project="$PROJECT_UUID"
while [[ $# -gt 0 ]]; do
case "$1" in
--project) project="$2"; shift 2 ;;
*) shift ;;
esac
done
local payload=$(jq -n \
--arg project "$project" \
--arg server "$SERVER_UUID" \
--arg name "$name" \
--arg user "$user" \
--arg pass "$pass" \
'{
project_uuid: $project,
environment_name: "production",
server_uuid: $server,
name: $name,
postgres_user: $user,
postgres_password: $pass,
postgres_db: $name,
image: "postgres:16-alpine",
is_public: false
}')
local result=$(curl -s -X POST -H "$AUTH" -H "$CT" -d "$payload" "$BASE/databases/postgresql")
echo "$result" | jq .
local db_uuid=$(echo "$result" | jq -r '.uuid // empty')
if [[ -n "$db_uuid" ]]; then
echo "Starting database..."
sleep 2
curl -s -H "$AUTH" "$BASE/databases/$db_uuid/start" | jq -r '.message'
fi
}
cmd_set_domain() {
local uuid=$(resolve_uuid "$1")
local domain="$2"
# Note: the Application API uses `domains` in PATCH but `fqdn` in GET.
# See https://github.com/coollabsio/coolify/issues/9502
curl -s -X PATCH -H "$AUTH" -H "$CT" \
-d "{\"domains\": \"$domain\"}" \
"$BASE/applications/$uuid" | jq -r '.uuid // .message'
}
# --- Config commands ---
cmd_set_env() {
local uuid=$(resolve_uuid "$1")
local key="$2"
local value="$3"
# If value is "-", read from stdin (for piping secrets without touching disk)
if [[ "$value" == "-" ]]; then
value=$(cat)
fi
local payload=$(jq -n --arg k "$key" --arg v "$value" '{key: $k, value: $v}')
curl -s -X POST -H "$AUTH" -H "$CT" -d "$payload" "$BASE/applications/$uuid/envs" | jq -r '.uuid // .message'
}
cmd_set_env_file() {
local uuid=$(resolve_uuid "$1")
local key="$2"
local file="$3"
if [[ ! -f "$file" ]]; then
echo "Error: file '$file' not found"
exit 1
fi
local value=$(cat "$file")
local payload=$(jq -n --arg k "$key" --arg v "$value" '{key: $k, value: $v}')
curl -s -X POST -H "$AUTH" -H "$CT" -d "$payload" "$BASE/applications/$uuid/envs" | jq -r '.uuid // .message'
}
cmd_del_env() {
local uuid=$(resolve_uuid "$1")
local key="$2"
local env_uuid=$(curl -s -H "$AUTH" "$BASE/applications/$uuid/envs" | jq -r ".[] | select(.key == \"$key\") | .uuid" | head -1)
if [[ -z "$env_uuid" ]]; then
echo "Error: env var '$key' not found"
exit 1
fi
curl -s -X DELETE -H "$AUTH" "$BASE/applications/$uuid/envs/$env_uuid" | jq -r '.message'
}
cmd_set_health() {
local uuid=$(resolve_uuid "$1")
local path="$2"
curl -s -X PATCH -H "$AUTH" -H "$CT" \
-d "{\"health_check_path\": \"$path\", \"health_check_enabled\": true}" \
"$BASE/applications/$uuid" | jq -r '.uuid // .message'
}
cmd_alias() {
local name="$1"
local uuid="$2"
register_app "$name" "$uuid"
echo "Registered: $name=$uuid"
}
# --- Storage commands (require Coolify beta.470+) ---
cmd_list_storage() {
local uuid=$(resolve_uuid "$1")
curl -s -H "$AUTH" "$BASE/applications/$uuid/storages" | jq .
}
cmd_add_volume() {
local uuid=$(resolve_uuid "$1")
local name="$2"
local mount_path="$3"
local payload=$(jq -n \
--arg name "$name" \
--arg mount "$mount_path" \
'{type: "persistent", name: $name, mount_path: $mount}')
curl -s -X POST -H "$AUTH" -H "$CT" -d "$payload" "$BASE/applications/$uuid/storages" | jq .
}
cmd_add_dir_mount() {
local uuid=$(resolve_uuid "$1")
local host_path="$2"
local mount_path="$3"
local payload=$(jq -n \
--arg host "$host_path" \
--arg mount "$mount_path" \
'{type: "file", is_directory: true, fs_path: $host, mount_path: $mount}')
curl -s -X POST -H "$AUTH" -H "$CT" -d "$payload" "$BASE/applications/$uuid/storages" | jq .
}
cmd_del_storage() {
local uuid=$(resolve_uuid "$1")
local storage_uuid="$2"
curl -s -X DELETE -H "$AUTH" "$BASE/applications/$uuid/storages/$storage_uuid" | jq .
}
# --- Low-level ---
cmd_api() {
local method="${1^^}"
local path="$2"
path="${path#/api/v1}"
path="${path#/}"
shift 2
local body="${1:-}"
if [[ -n "$body" ]]; then
curl -s -X "$method" -H "$AUTH" -H "$CT" -d "$body" "$BASE/$path" | jq .
else
curl -s -X "$method" -H "$AUTH" "$BASE/$path" | jq .
fi
}
# --- Dispatch ---
[[ $# -lt 1 ]] && usage
case "$1" in
apps) cmd_apps ;;
status) [[ $# -lt 2 ]] && usage; cmd_status "$2" ;;
env) [[ $# -lt 2 ]] && usage; cmd_env "$2" ;;
dbs) cmd_dbs ;;
deploy-status) [[ $# -lt 2 ]] && usage; cmd_deploy_status "$2" ;;
app-logs) [[ $# -lt 2 ]] && usage; shift; cmd_app_logs "$@" ;;
deploy) [[ $# -lt 2 ]] && usage; cmd_deploy "$2" ;;
logs) [[ $# -lt 2 ]] && usage; cmd_logs "$2" ;;
tail) [[ $# -lt 2 ]] && usage; cmd_tail "$2" ;;
restart) [[ $# -lt 2 ]] && usage; cmd_restart "$2" ;;
exec) [[ $# -lt 3 ]] && usage; shift; cmd_exec "$@" ;;
create-app) [[ $# -lt 2 ]] && usage; shift; cmd_create_app "$@" ;;
create-db) [[ $# -lt 4 ]] && usage; shift; cmd_create_db "$@" ;;
set-domain) [[ $# -lt 3 ]] && usage; cmd_set_domain "$2" "$3" ;;
set-env) [[ $# -lt 4 ]] && usage; cmd_set_env "$2" "$3" "$4" ;;
set-env-file) [[ $# -lt 4 ]] && usage; cmd_set_env_file "$2" "$3" "$4" ;;
del-env) [[ $# -lt 3 ]] && usage; cmd_del_env "$2" "$3" ;;
set-health) [[ $# -lt 3 ]] && usage; cmd_set_health "$2" "$3" ;;
alias) [[ $# -lt 3 ]] && usage; cmd_alias "$2" "$3" ;;
list-storage) [[ $# -lt 2 ]] && usage; cmd_list_storage "$2" ;;
add-volume) [[ $# -lt 4 ]] && usage; cmd_add_volume "$2" "$3" "$4" ;;
add-dir-mount) [[ $# -lt 4 ]] && usage; cmd_add_dir_mount "$2" "$3" "$4" ;;
del-storage) [[ $# -lt 3 ]] && usage; cmd_del_storage "$2" "$3" ;;
api) [[ $# -lt 3 ]] && usage; shift; cmd_api "$@" ;;
help|--help|-h) usage ;;
*) usage ;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment