2026-02-27 08:55:41 -06:00
2026-02-27 08:55:41 -06:00
2026-02-27 08:55:41 -06:00
2026-02-27 08:55:41 -06:00
2026-01-26 22:33:55 -06:00
2026-02-27 08:55:41 -06:00
2026-01-26 22:33:55 -06:00
2026-02-27 08:55:41 -06:00

Docker Deployment Manager → Coolify Migration

This project started as a custom Docker deployment tool (CLI + Electron desktop app) for deploying from Windows to Linux servers via SSH/tar transfers. After evaluating the approach, we're migrating to Coolify — an open-source self-hosted PaaS that handles the entire deploy pipeline automatically.

Why Coolify Instead of a Custom Tool?

Custom Tool Coolify
Manual button clicks to deploy Auto-deploys on git push
SSH password stored in plaintext .env SSH keys / deploy keys
Tar-based image transfer (no registry) Builds on server, no transfer needed
Desktop app required on Windows Web dashboard from any browser
Custom file-diff comparison Git is the source of truth
One developer maintaining it Thousands of contributors

Architecture

Windows (dev machine)               192.168.69.4 (box-stableapps)
┌──────────────────────┐            ┌──────────────────────────────┐
│ C:\.bucket\repos.gitea│            │ Coolify (port 2010)          │
│                       │            │   ├── Dashboard + DB         │
│  edit code            │            │   ├── Traefik proxy          │
│    → git commit       │            │   └── Manages deployments    │
│    → git push         │            │                              │
│         │             │            │ Gitea (ports 2003/2004)      │
│         ▼             │            │   ├── HTTP: 2003             │
│  Gitea (webhook) ────────────►    │   └── SSH:  2004             │
│  gitea.clintmasden    │            │                              │
│    .duckdns.org       │            │ Data: ~/containers/*/data/   │
└──────────────────────┘            └──────────────────────────────┘
                                              │
                                              │ SSH (root)
                                              ▼
                                    192.168.69.5 (box-repoapps)
                                    ┌──────────────────────────────┐
                                    │ Deployed apps:               │
                                    │   ├── learnxiny              │
                                    │   ├── actualbudget.report    │
                                    │   └── ...                    │
                                    │                              │
                                    │ Data: ~/containers/*/data/   │
                                    └──────────────────────────────┘

Flow: edit → git push → Gitea webhook → Coolify auto-builds & deploys

Server Setup

  • 192.168.69.4 (box-stableapps): Coolify host + Gitea host + stable apps
  • 192.168.69.5 (box-repoapps): Working/repo apps, managed as remote server by Coolify

Coolify Installation (on 192.168.69.4)

Coolify runs as Docker containers at ~/containers/coolify/, consistent with the existing container layout.

1. Create directories

mkdir -p ~/containers/coolify/data/{source,ssh,applications,databases,backups,services,proxy,sentinel}
mkdir -p ~/containers/coolify/data/ssh/{keys,mux}
mkdir -p ~/containers/coolify/data/proxy/dynamic

2. Create the .env file

tee ~/containers/coolify/data/source/.env << 'ENVEOF'
APP_ID=coolify
APP_NAME=Coolify
APP_ENV=production
APP_KEY=
DB_USERNAME=coolify
DB_PASSWORD=
REDIS_PASSWORD=
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
APP_PORT=2010
SOKETI_PORT=6001
REGISTRY_URL=ghcr.io
ENVEOF

Generate secrets:

sed -i "s|^APP_KEY=.*|APP_KEY=base64:$(openssl rand -base64 32)|" ~/containers/coolify/data/source/.env
sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=$(openssl rand -base64 32)|" ~/containers/coolify/data/source/.env
sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(openssl rand -base64 32)|" ~/containers/coolify/data/source/.env
sed -i "s|^PUSHER_APP_ID=.*|PUSHER_APP_ID=$(openssl rand -hex 32)|" ~/containers/coolify/data/source/.env
sed -i "s|^PUSHER_APP_KEY=.*|PUSHER_APP_KEY=$(openssl rand -hex 32)|" ~/containers/coolify/data/source/.env
sed -i "s|^PUSHER_APP_SECRET=.*|PUSHER_APP_SECRET=$(openssl rand -hex 32)|" ~/containers/coolify/data/source/.env

3. Create docker-compose.yml

Use a single merged compose file at ~/containers/coolify/data/source/docker-compose.yml. Key customizations from default:

  • All paths use /home/clint/containers/coolify/data/ instead of /data/coolify/
  • Named volumes replaced with bind mounts (postgres, redis)
  • APP_PORT=2010 instead of default 8000
  • host.docker.internal enabled for accessing other containers on the host
  • Gitea known_hosts mounted for SSH git access
services:
  coolify:
    image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}"
    container_name: coolify
    restart: always
    working_dir: /var/www/html
    extra_hosts:
      - host.docker.internal:host-gateway
    volumes:
      - type: bind
        source: /home/clint/containers/coolify/data/source/.env
        target: /var/www/html/.env
        read_only: true
      - /home/clint/containers/coolify/data/ssh:/var/www/html/storage/app/ssh
      - /home/clint/containers/coolify/data/applications:/var/www/html/storage/app/applications
      - /home/clint/containers/coolify/data/databases:/var/www/html/storage/app/databases
      - /home/clint/containers/coolify/data/services:/var/www/html/storage/app/services
      - /home/clint/containers/coolify/data/backups:/var/www/html/storage/app/backups
      - /home/clint/containers/coolify/data/known_hosts:/root/.ssh/known_hosts:ro
    environment:
      - APP_ENV=${APP_ENV:-production}
      - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M}
    env_file:
      - /home/clint/containers/coolify/data/source/.env
    ports:
      - "${APP_PORT:-8000}:8080"
    expose:
      - "${APP_PORT:-8000}"
    healthcheck:
      test: curl --fail http://127.0.0.1:8080/api/health || exit 1
      interval: 5s
      retries: 10
      timeout: 2s
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      soketi:
        condition: service_healthy
    networks:
      - coolify

  postgres:
    image: postgres:15-alpine
    container_name: coolify-db
    restart: always
    volumes:
      - /home/clint/containers/coolify/data/postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: "${DB_USERNAME}"
      POSTGRES_PASSWORD: "${DB_PASSWORD}"
      POSTGRES_DB: "${DB_DATABASE:-coolify}"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}"]
      interval: 5s
      retries: 10
      timeout: 2s
    networks:
      - coolify

  redis:
    image: redis:7-alpine
    container_name: coolify-redis
    restart: always
    command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
    environment:
      REDIS_PASSWORD: "${REDIS_PASSWORD}"
    volumes:
      - /home/clint/containers/coolify/data/redis:/data
    healthcheck:
      test: redis-cli ping
      interval: 5s
      retries: 10
      timeout: 2s
    networks:
      - coolify

  soketi:
    image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10"
    container_name: coolify-realtime
    restart: always
    extra_hosts:
      - host.docker.internal:host-gateway
    ports:
      - "${SOKETI_PORT:-6001}:6001"
      - "6002:6002"
    volumes:
      - /home/clint/containers/coolify/data/ssh:/var/www/html/storage/app/ssh
    environment:
      APP_NAME: "${APP_NAME:-Coolify}"
      SOKETI_DEBUG: "${SOKETI_DEBUG:-false}"
      SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
      SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
      SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"]
      interval: 5s
      retries: 10
      timeout: 2s
    networks:
      - coolify

networks:
  coolify:
    name: coolify
    external: true

4. Set up Traefik proxy

Coolify uses Traefik as its reverse proxy. The proxy has its own docker-compose at ~/containers/coolify/data/proxy/docker-compose.yml:

name: coolify-proxy
networks:
  coolify:
    external: true
services:
  traefik:
    container_name: coolify-proxy
    image: 'traefik:v3.6'
    restart: unless-stopped
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    networks:
      - coolify
    ports:
      - '80:80'
      - '443:443'
      - '443:443/udp'
      - '8080:8080'
    healthcheck:
      test: 'wget -qO- http://localhost:80/ping || exit 1'
      interval: 4s
      timeout: 2s
      retries: 5
    volumes:
      - '/var/run/docker.sock:/var/run/docker.sock:ro'
      - '/home/clint/containers/coolify/data/proxy/:/traefik'
    command:
      - '--ping=true'
      - '--ping.entrypoint=http'
      - '--api.dashboard=true'
      - '--entrypoints.http.address=:80'
      - '--entrypoints.https.address=:443'
      - '--entrypoints.http.http.encodequerysemicolons=true'
      - '--entryPoints.http.http2.maxConcurrentStreams=250'
      - '--entrypoints.https.http.encodequerysemicolons=true'
      - '--entryPoints.https.http2.maxConcurrentStreams=250'
      - '--entrypoints.https.http3'
      - '--providers.file.directory=/traefik/dynamic/'
      - '--providers.file.watch=true'
      - '--certificatesresolvers.letsencrypt.acme.httpchallenge=true'
      - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=http'
      - '--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json'
      - '--api.insecure=false'
      - '--providers.docker=true'
      - '--providers.docker.exposedbydefault=false'
    labels:
      - traefik.enable=true
      - traefik.http.routers.traefik.entrypoints=http
      - traefik.http.routers.traefik.service=api@internal
      - traefik.http.services.traefik.loadbalancer.server.port=8080
      - coolify.managed=true
      - coolify.proxy=true

Key: the volume mount points to /home/clint/containers/coolify/data/proxy/ (not /data/coolify/proxy/).

5. Create Docker network and start

Run these as separate commands:

sudo docker network create --attachable coolify
# Start the proxy first
cd ~/containers/coolify/data/proxy
sudo docker compose up -d

# Then start Coolify
cd ~/containers/coolify/data/source
sudo docker compose up -d --pull always --remove-orphans --force-recreate

6. Fix data directory permissions

Each service needs specific ownership. Do NOT blanket chown the entire data directory — Postgres and Redis run as different UIDs inside their containers.

# Postgres must be UID 70 (postgres user in Alpine)
sudo chown -R 70:70 ~/containers/coolify/data/postgres

# Redis must be UID 999 (redis user in Alpine)
sudo chown -R 999:999 ~/containers/coolify/data/redis

# SSH keys — Coolify runs as UID 9999
sudo chown -R 9999:root ~/containers/coolify/data/ssh
sudo chmod -R 700 ~/containers/coolify/data/ssh

# Proxy — root-owned, acme.json must be 600
sudo chown -R root:root ~/containers/coolify/data/proxy
sudo chmod 600 ~/containers/coolify/data/proxy/acme.json

# Everything else can be clint
sudo chown -R clint:clint ~/containers/coolify/data/source
sudo chown -R clint:clint ~/containers/coolify/data/applications
sudo chown -R clint:clint ~/containers/coolify/data/databases
sudo chown -R clint:clint ~/containers/coolify/data/services
sudo chown -R clint:clint ~/containers/coolify/data/backups

If you accidentally chown -R the whole directory, fix it with the commands above and restart: docker restart coolify-db coolify-redis coolify.

7. Open dashboard

Go to http://192.168.69.4:2010 and create admin account (first visitor gets admin).


Gitea SSH Fix (Required for Coolify)

By default, Gitea's Docker image does NOT start its built-in SSH server. This means SSH keys added through Gitea's web UI won't work for git clone over SSH. Coolify needs SSH to clone private repos.

The problem

Gitea's Docker container has OpenSSH running on port 22 internally, but it's not connected to Gitea's user/key database. Keys added in the Gitea web UI are only used by Gitea's built-in SSH server, which is disabled by default.

The fix

Add these environment variables to your Gitea docker-compose (~/containers/gitea/docker-compose.yml):

  gitea:
    environment:
      # ... existing env vars ...
      GITEA__server__START_SSH_SERVER: "true"
      GITEA__server__SSH_LISTEN_PORT: "2222"
      GITEA__server__ROOT_URL: "https://gitea.clintmasden.duckdns.org/"
      GITEA__server__SSH_DOMAIN: "192.168.69.4"
      GITEA__webhook__ALLOWED_HOST_LIST: "192.168.69.4,host.docker.internal"
    ports:
      - "2003:3000"
      - "2004:2222"    # was 2004:22, now points to Gitea's built-in SSH
  • START_SSH_SERVER=true — enables Gitea's built-in SSH server that reads keys from its database
  • SSH_LISTEN_PORT=2222 — avoids conflict with OpenSSH already on port 22 inside the container
  • Port mapping changed from 2004:22 to 2004:2222 to match
  • ROOT_URL — must match how you access Gitea in the browser. Mismatch causes webhooks not to fire on real pushes
  • SSH_DOMAIN — keeps the IP for SSH clone URLs (Coolify connects via ssh://git@192.168.69.4:2004/...)
  • ALLOWED_HOST_LIST — Gitea blocks webhooks to local/private IPs by default. This allows webhooks to reach Coolify on the same machine

Restart Gitea:

cd ~/containers/gitea && docker compose up -d

Verify SSH works:

ssh -p 2004 git@192.168.69.4
# Should respond with: "Hi <username>! You've successfully authenticated..."

Connecting Coolify to Gitea

1. Generate SSH key in Coolify

In Coolify dashboard → Keys & Tokens → create a new private key (e.g., "gitea-key").

2. Add public key to Gitea (one-time, all repos)

Copy the public key from Coolify's key detail page. In Gitea → User Settings → SSH / GPG Keys → Add Key → paste it.

This gives Coolify access to ALL your repos under that user. No need to add deploy keys per-repo.

3. Set up Gitea's known_hosts for Coolify

The Coolify container needs to trust Gitea's SSH host key. Create a persistent known_hosts file:

ssh-keyscan -p 2004 192.168.69.4 2>/dev/null | sudo tee /home/clint/containers/coolify/data/known_hosts > /dev/null

This file is mounted into the Coolify container via the docker-compose volume:

- /home/clint/containers/coolify/data/known_hosts:/root/.ssh/known_hosts:ro

4. Add remote server (192.168.69.5)

Coolify needs root SSH access to remote servers:

On 192.168.69.5:

  1. Add Coolify's SSH public key to /root/.ssh/authorized_keys
  2. Ensure /etc/ssh/sshd_config has PermitRootLogin prohibit-password
  3. sudo systemctl restart sshd

In Coolify: Servers → Add Server → SSH user root, IP 192.168.69.5.

Do the same for localhost (192.168.69.4) if you want Coolify to manage apps on the same box.

5. Add an application

  1. Projects → Add Project → name it
  2. Inside the project → Add Resource → Application
  3. Choose Private Repository (with deploy key)
  4. Select the gitea-key private key
  5. Repository URL (must include port via ssh:// format):
    ssh://git@192.168.69.4:2004/clint/your-repo.git
    
  6. Choose Docker Compose as build pack
  7. Click Load Compose File
  8. Set environment variables in Coolify's UI

SSH URL format — important

Because Gitea SSH is on port 2004 (not 22), you must use the ssh:// URL format:

ssh://git@192.168.69.4:2004/clint/repo-name.git

The standard git@host:user/repo.git format does NOT support custom ports and will fail.


Deployment Repo Structure

Each service gets a Gitea repo with Docker config:

dotrepo.learn.x.in.y/
├── react/
│   ├── Dockerfile
│   ├── docker-compose.yml    ← absolute volume paths
│   ├── server.js
│   ├── package.json
│   └── src/

Volume paths — absolute, not relative

# WRONG — relative path breaks with Coolify
volumes:
  - ./data:/app/data

# CORRECT — absolute path, data stays where it already is
volumes:
  - /home/clint/containers/learnxiny/data:/data

Environment variables

.env files stay in .gitignore — never committed. Production env vars are managed in Coolify's dashboard per-app. Coolify auto-detects ${VARIABLE} syntax in compose files and creates UI fields for them.


Auto-deploy Webhook

The webhook URL and secret are global — same for every repo. Coolify matches incoming webhooks to the right app based on the repo URL in the payload.

One-time: get the URL and secret from Coolify

  1. Open any app in Coolify → Webhooks tab
  2. Under Manual Git Webhooks → Gitea, copy the URL (e.g., http://192.168.69.4:2010/webhooks/source/gitea/events/manual)
  3. Set a Gitea Webhook Secret and save

Per-repo: add webhook in Gitea

For each Gitea repo you want to auto-deploy:

  1. Repo → Settings → Webhooks → Add Webhook → Gitea
  2. Target URL: the Coolify Gitea webhook URL from above
  3. HTTP Method: POST
  4. Content Type: application/json
  5. Secret: the same secret you set in Coolify
  6. Trigger On: Push Events
  7. Save

Test by clicking Test Push Event in the webhook, then check Recent Deliveries for a green 200 response.

Gitea webhook allow-list

Gitea blocks webhooks to private/local IPs by default. The GITEA__webhook__ALLOWED_HOST_LIST environment variable in Gitea's docker-compose must include the Coolify host IP — see Gitea SSH Fix section.

Now every git push triggers: clone → build → deploy → health check — automatically.


Traefik — Replacing Caddy

Coolify manages Traefik as its built-in reverse proxy. Once all services are migrated, Traefik replaces Caddy entirely.

Coolify dashboard domain

In Coolify → Settings → set Instance's Domain (FQDN) to:

https://coolify.clintmasden.duckdns.org

Coolify auto-configures Traefik to route this domain to its dashboard with SSL.

Coolify-managed apps

For apps deployed through Coolify, set the domain in the app's settings. Coolify configures Traefik automatically — no manual config needed.

Non-Coolify services (Gitea, Syncthing, etc.)

For services NOT managed by Coolify, add custom Traefik config at:

~/containers/coolify/data/proxy/dynamic/custom.yml

http:
  routers:
    gitea-web:
      rule: "Host(`gitea.clintmasden.duckdns.org`)"
      entryPoints:
        - https
      service: gitea-web
      tls:
        certResolver: letsencrypt

    syncthing:
      rule: "Host(`syncthing.clintmasden.duckdns.org`)"
      entryPoints:
        - https
      service: syncthing
      tls:
        certResolver: letsencrypt

    immich:
      rule: "Host(`immich.clintmasden.duckdns.org`)"
      entryPoints:
        - https
      service: immich
      tls:
        certResolver: letsencrypt

    affine:
      rule: "Host(`affine.clintmasden.duckdns.org`)"
      entryPoints:
        - https
      service: affine
      tls:
        certResolver: letsencrypt

    mattermost:
      rule: "Host(`mattermost.clintmasden.duckdns.org`)"
      entryPoints:
        - https
      service: mattermost
      tls:
        certResolver: letsencrypt

    vikunja:
      rule: "Host(`vikunja.clintmasden.duckdns.org`)"
      entryPoints:
        - https
      service: vikunja
      tls:
        certResolver: letsencrypt

  services:
    gitea-web:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:2003"

    syncthing:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:2000"

    immich:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:2001"

    affine:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:2005"

    mattermost:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:2006"

    vikunja:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:2009"

Traefik picks up changes automatically — no restart needed.

Switching from Caddy to Traefik

  1. Stop Caddy: cd ~/containers/caddy && docker compose down
  2. Verify Traefik grabs ports 80/443: docker ps | grep traefik
  3. If Traefik isn't binding, restart it: docker restart coolify-proxy
  4. Test: curl -I https://gitea.clintmasden.duckdns.org
  5. If something breaks, bring Caddy back: cd ~/containers/caddy && docker compose up -d

Services that still need migration

These Caddy static sites need to be containerized (simple nginx containers) before Caddy can be fully retired:

  • dotrepo.com + subdomains: fitnotes, racker, timer, travel, pianoxml (React/Angular SPAs served from /sites/)
  • audiosphere.dotrepo.com, audiocapsule.dotrepo.com, audioevents.dotrepo.com, everynoise.dotrepo.com (proxied to 192.168.69.5)
  • clintmasden.duckdns.org (simple "Hi, I'm Clint" response)

Troubleshooting

"Could not resolve hostname ssh/http"

Coolify's custom git repo field only supports SSH. It strips protocol prefixes. Use the ssh:// format with port for non-standard SSH ports.

"Permission denied (publickey)" on git clone

  1. Verify the right private key is selected in the application settings
  2. Verify the matching public key is in Gitea (User Settings → SSH Keys, not repo deploy keys)
  3. Test from inside Coolify: docker exec coolify sh -c "ssh -p 2004 -i /var/www/html/storage/app/ssh/keys/<key-file> git@192.168.69.4"
  4. Debug with verbose SSH: docker exec coolify sh -c "ssh -vvv -p 2004 -i /var/www/html/storage/app/ssh/keys/<key-file> -o StrictHostKeyChecking=no git@192.168.69.4 2>&1 | tail -30"

"Host key verification failed"

Regenerate known_hosts: ssh-keyscan -p 2004 192.168.69.4 2>/dev/null | sudo tee /home/clint/containers/coolify/data/known_hosts > /dev/null

SSH key storage not writable

sudo chown -R 9999:root ~/containers/coolify/data/ssh
sudo chmod -R 700 ~/containers/coolify/data/ssh

500 error / Redis MISCONF / Postgres permission denied

Usually caused by blanket chown on the data directory. Fix ownership per-service and restart:

sudo chown -R 70:70 ~/containers/coolify/data/postgres
sudo chown -R 999:999 ~/containers/coolify/data/redis
sudo chown -R root:root ~/containers/coolify/data/proxy
sudo chmod 600 ~/containers/coolify/data/proxy/acme.json
docker restart coolify-db coolify-redis coolify

Gitea SSH rejects all keys

Enable Gitea's built-in SSH server — see Gitea SSH Fix section above.

Webhook "ALLOWED_HOST_LIST" error

Gitea blocks webhooks to private IPs by default. Add GITEA__webhook__ALLOWED_HOST_LIST: "192.168.69.4,host.docker.internal" to Gitea's docker-compose environment and restart.

Webhooks fire on test but not on real push

Check Gitea's ROOT_URL setting. If it doesn't match the URL you use to access Gitea (e.g., set to http://192.168.69.4:2003 but you access via gitea.clintmasden.duckdns.org), webhooks won't fire on actual push events. Fix by setting GITEA__server__ROOT_URL in the docker-compose.

Useful debug commands

List Coolify's SSH keys:

docker exec coolify sh -c "ls /var/www/html/storage/app/ssh/keys/"

Show public key for each stored key:

docker exec coolify sh -c "for key in /var/www/html/storage/app/ssh/keys/*; do echo \"--- \$key ---\"; ssh-keygen -y -f \$key 2>&1; done"

Note: Coolify's container is Alpine-based — use sh not bash for docker exec.


Workflow Summary

Before (manual):
  edit → build tar → SSH upload → docker load → docker compose up → hope it works

After (Coolify):
  edit → git commit → git push → done (Coolify handles the rest)

Built for managing 35+ Docker deployments across two servers via Gitea + Coolify.

Description
Docker Deployment Manager - REST API + Electron app for managing Docker deployments via Gitea + Coolify
Readme 154 KiB
Languages
JavaScript 93.2%
CSS 3.6%
HTML 2.4%
PowerShell 0.8%