24 KiB
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=2010instead of default 8000host.docker.internalenabled 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 databaseSSH_LISTEN_PORT=2222— avoids conflict with OpenSSH already on port 22 inside the container- Port mapping changed from
2004:22to2004:2222to match ROOT_URL— must match how you access Gitea in the browser. Mismatch causes webhooks not to fire on real pushesSSH_DOMAIN— keeps the IP for SSH clone URLs (Coolify connects viassh://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:
- Add Coolify's SSH public key to
/root/.ssh/authorized_keys - Ensure
/etc/ssh/sshd_confighasPermitRootLogin prohibit-password 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
- Projects → Add Project → name it
- Inside the project → Add Resource → Application
- Choose Private Repository (with deploy key)
- Select the gitea-key private key
- Repository URL (must include port via ssh:// format):
ssh://git@192.168.69.4:2004/clint/your-repo.git - Choose Docker Compose as build pack
- Click Load Compose File
- 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
- Open any app in Coolify → Webhooks tab
- Under Manual Git Webhooks → Gitea, copy the URL (e.g.,
http://192.168.69.4:2010/webhooks/source/gitea/events/manual) - Set a Gitea Webhook Secret and save
Per-repo: add webhook in Gitea
For each Gitea repo you want to auto-deploy:
- Repo → Settings → Webhooks → Add Webhook → Gitea
- Target URL: the Coolify Gitea webhook URL from above
- HTTP Method: POST
- Content Type: application/json
- Secret: the same secret you set in Coolify
- Trigger On: Push Events
- 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
- Stop Caddy:
cd ~/containers/caddy && docker compose down - Verify Traefik grabs ports 80/443:
docker ps | grep traefik - If Traefik isn't binding, restart it:
docker restart coolify-proxy - Test:
curl -I https://gitea.clintmasden.duckdns.org - 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
- Verify the right private key is selected in the application settings
- Verify the matching public key is in Gitea (User Settings → SSH Keys, not repo deploy keys)
- 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" - 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.