Created
April 11, 2026 23:36
-
-
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/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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