Share
Examples

Example: Credential-Gated Deploy to Coolify + Container Registry

End-to-End: Build → Registry Push → Human-Gated Coolify Deploy

This example shows a two-station Assembly Line that builds and publishes a Docker image to a container registry, then deploys it via Coolify — without the Agent ever holding a real API key.

It demonstrates the full ALP credential architecture described in spec/11-security.md.

The swimlane diagram above shows the complete flow from image build through to the human-gated Coolify deploy.


Setup

The Runner runs on the same Ubuntu 24.04 host as Coolify (matching the setup in docs/self-hosted-runner.md). This means the Runner has inherent network access to Coolify's local API. The credential architecture ensures Agents cannot abuse this access.

Runner Credential Configuration

# ~/.pks-cli/runner-credentials.yaml   (never mounted into containers)

credentials:
  - service: registry
    endpoint: ghcr.io
    username: pksorensen
    token: "${GHCR_TOKEN}"          # ghp_ PAT with packages:write
    scopes: ["push", "pull"]
    allowed_labels: ["build", "deploy"]

  - service: coolify
    endpoint: https://coolify.internal/api/v1
    token: "${COOLIFY_API_TOKEN}"   # Coolify API key
    scopes: ["deploy", "read"]
    allowed_labels: ["deploy"]
    # production deploys require human approval at the DMZ level:
    require_approval_for_paths: ["/api/v1/deploy", "/api/v1/restart"]

egress_policy:
  # Allow: package registry, GitHub API
  - destination: "ghcr.io"
    allow: true
  - destination: "api.github.com"
    allow: true
  # Require approval: Coolify deploy endpoints
  - destination: "coolify.internal/api/v1/deploy"
    method: POST
    require_human_approval: true
  # Block everything else by default
  - destination: "*"
    allow: false

Assembly Line: BUILD → DEPLOY

{
  "name": "Build and Deploy",
  "description": "Build a Docker image, push to ghcr.io, deploy via Coolify.",
  "columns": [
    {
      "id": "build",
      "name": "BUILD",
      "order": 0,
      "trigger": {
        "type": "dispatch_worker",
        "promptTemplate": "Build and push the Docker image for this deployment.\n\n{{task.description}}\n\nSteps:\n1. Read the task description to find: repository URL, image name, tag\n2. Clone the repository\n3. Get registry credentials: curl --unix-socket /run/alp/cred.sock 'http://localhost/token?service=registry&scopes=push'\n4. Log in to the registry using the returned token\n5. Build the Docker image: docker build -t <image>:<tag> .\n6. Push the image: docker push <image>:<tag>\n7. Write the image digest to workspace/image-digest.txt\n8. Call complete_station with conclusion: success",
        "appendSystemPrompt": "You are a build engineer. Use the ALP credential server at /run/alp/cred.sock to get registry credentials — never use hardcoded tokens. Build the image exactly as specified. Write the pushed image digest to workspace/image-digest.txt before completing.",
        "labels": ["build", "docker"],
        "idleTimeoutMinutes": 5,
        "maxTimeoutMinutes": 30
      }
    },
    {
      "id": "deploy",
      "name": "DEPLOY",
      "order": 1,
      "trigger": {
        "type": "dispatch_worker",
        "promptTemplate": "Deploy the built image via Coolify.\n\n{{task.description}}\n\nSteps:\n1. Read workspace/image-digest.txt to get the image digest\n2. Read the task description to find: Coolify application ID, environment\n3. Get a deploy token: curl --unix-socket /run/alp/cred.sock 'http://localhost/token?service=coolify&scopes=deploy'\n4. Trigger the deployment via the ALP egress proxy:\n   curl -X POST https://coolify.internal/api/v1/deploy \\\n     -H 'Authorization: Bearer <jit-token>' \\\n     -H 'Content-Type: application/json' \\\n     -d '{\"applicationId\": \"<app-id>\", \"imageDigest\": \"<digest>\"}'\n   NOTE: The proxy will hold this request for human approval before forwarding.\n5. Wait for the 200 response (it will come after a human approves the deploy)\n6. Log the deployment URL from the response\n7. Call complete_station with conclusion: success and summary including the deploy URL",
        "appendSystemPrompt": "You are a deployment engineer. Use the ALP credential server for all credentials. Your deploy call will be held for human approval — this is expected, just wait for the response. Never bypass the proxy. Record the deployment URL in your summary.",
        "labels": ["deploy"],
        "idleTimeoutMinutes": 60,
        "maxTimeoutMinutes": 90
      }
    }
  ],
  "transitionRules": [
    { "fromColumnId": "build", "toColumnId": "deploy", "condition": "success" }
  ]
}

The Credential Flow in Detail

Step 1 — BUILD station: registry token

The Agent inside the BUILD devcontainer calls the credential server:

# Inside the devcontainer (sandbox)
CRED=$(curl -s --unix-socket /run/alp/cred.sock \
  "http://localhost/token?service=registry&scopes=push")

TOKEN=$(echo $CRED | jq -r .token)
ENDPOINT=$(echo $CRED | jq -r .endpoint)

# Token is a scoped JIT token: alp_proxy_jit_aaa...
# Endpoint is: ghcr.io

# Log in — docker traffic routes through http_proxy → Runner swaps token
echo "$TOKEN" | docker login $ENDPOINT -u token --password-stdin

# Runner's proxy intercepts the docker login request,
# recognises alp_proxy_jit_aaa, replaces with real GHCR_TOKEN,
# forwards to ghcr.io. Agent never saw the real token.

docker build -t ghcr.io/pksorensen/my-app:${GIT_SHA} .
docker push ghcr.io/pksorensen/my-app:${GIT_SHA}

What each party holds:

PartyToken it hasCan it reach ghcr.io directly?
Runner (host)Real GHCR_TOKEN (ghp_...)Yes — but doesn't expose it
Agent (container)alp_proxy_jit_aaa (JIT, TTL=job)Only through the proxy
Proxy (Runner)Swaps on the wireForwards with real token

Step 2 — DEPLOY station: Coolify deploy with human gate

The DEPLOY station Agent requests a deploy token and calls the Coolify API:

# Inside the devcontainer
CRED=$(curl -s --unix-socket /run/alp/cred.sock \
  "http://localhost/token?service=coolify&scopes=deploy")
JIT_TOKEN=$(echo $CRED | jq -r .token)

IMAGE_DIGEST=$(cat workspace/image-digest.txt)

# This request goes: Agent → Egress Proxy → [HELD] → Human Approver → Coolify
# The Agent just makes a normal HTTP call and waits:
RESPONSE=$(curl -s -X POST https://coolify.internal/api/v1/deploy \
  -H "Authorization: Bearer $JIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"applicationId\": \"app-abc123\", \"imageDigest\": \"$IMAGE_DIGEST\"}")

What happens at the proxy:

Agent POST /api/v1/deploy  ──► Egress Proxy
                                    │
                          policy: require_human_approval=true
                                    │
                          ──► ALP Server: POST /runners/{id}/approval-requests
                                    │     {url, method, body, jobId, labels}
                                    │
                          ALP Server shows in UI ──► Human reviews
                                    │
                          Human clicks "Approve"
                                    │
                          ──► Proxy receives approval
                                    │
                          Proxy swaps JIT token → real COOLIFY_API_TOKEN
                                    │
                          ──► Coolify API (real request forwarded)
                                    │
                          ◄── 200 OK {deployUrl: "https://..."}
                                    │
                          ◄── Agent receives response (after ~seconds of human review)

The Agent's curl call simply blocks until the proxy resolves the approval. From the Agent's perspective, the call just took longer than usual.


Task Card Example

Title: Deploy my-app v2.3.1 to production

Description:
  Repository:    https://github.com/pksorensen/my-app
  Branch:        main
  Git SHA:       a1b2c3d
  Image name:    ghcr.io/pksorensen/my-app
  Tag:           2.3.1
  Coolify App:   app-abc123
  Environment:   production
  Notes:         Includes the new auth refactor — review the deploy carefully.

Why Not Just Give the Agent the API Key?

ApproachRisk if agent is compromised
Agent has real Coolify API key in envAttacker can deploy anything, delete apps, read secrets
Agent has real registry token in envAttacker can push malicious images to the registry
ALP credential server (this example)Attacker has a JIT token that expires when the job ends, only works through the proxy, and can only trigger the specific operations the Station's labels permit

The ALP credential model is directly inspired by how GitHub Actions OIDC works with Azure: the CI job never holds a static cloud credential. Its identity (workload identity) is used to request a short-lived, scoped access token at runtime. If the runner environment is compromised, the blast radius is bounded.


Current Implementation Status

This example represents the target architecture. The current state:

FeatureStatus
Runner on Coolify host✅ Implemented (docs/self-hosted-runner.md)
Coolify access from runner✅ Same host, network reachable
Registry credentials (manual)✅ Set as GitHub Actions secrets
ALP Credential Server socket⬜ Not yet implemented in pks-cli
Egress Proxy with token swap⬜ Not yet implemented
Human DMZ gate⬜ Planned for pks-cli v2
Workload identity label gating⬜ Partially implemented (Job ID injected; label check not wired)

Today, the pattern is approximated by injecting credentials as env vars (e.g. ASSEMBLY_LINE_REPO_TOKEN). The credential server is the next evolution, narrowing the blast radius significantly.