# Can I self-host Vercel?

**YES, BUT** — it's called Coolify. ONE WEEKEND setup · ~5 hours to running · 2 GB RAM minimum · $20/mo you stop paying ($240/yr on the Pro plan).

Coolify authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/vercel/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Coolify 4.1.2 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.

Say this before anything installs. Coolify holds a private key that logs into this host and
drives its Docker daemon: Prompt Zero called docker-group membership root-equivalent, and this
hands that to a web dashboard.

It needs 2048 MB of RAM available and 30 GB free on /, a floor rather than a budget because
builds run here too. Both architectures are published.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail / | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk under 30 GB, print both and stop. If `dig +short`
prints nothing, print that and stop too. Do not install and hope.

## 2. Layout and host access

The application writes deployment files to /data/coolify by name, so that path is not ours to
move. Our archives sit outside it, in /srv/coolify/backups.

```bash
sudo install -d -m 750 -o "$(id -u)" -g 9999 /data/coolify /data/coolify/source
sudo install -d -m 700 -o 9999 -g 9999 /data/coolify/{ssh,ssh/keys,ssh/mux,applications,databases,services,backups,proxy,proxy/dynamic}
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" /srv/coolify /srv/coolify/backups
KEY=/data/coolify/ssh/keys/id.$(id -un)@host.docker.internal
sudo ssh-keygen -t ed25519 -a 100 -N "" -C coolify -q -f "$KEY"
sudo cat "$KEY.pub" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
sudo rm -f "$KEY.pub"
sudo chown 9999:9999 "$KEY" && sudo chmod 600 "$KEY"
printf '%s ALL=(ALL) %s ALL\n' "$(id -un)" 'NOPASSWD:' | sudo tee /etc/sudoers.d/coolify >/dev/null
sudo chmod 440 /etc/sudoers.d/coolify
sudo visudo -c -f /etc/sudoers.d/coolify
docker network create --attachable coolify || true
ls -la /data/coolify
```

Assert: `visudo -c` prints `parsed OK`, and `ls -la` shows `source` owned by the login user, the
rest at mode `700` owned by `9999`. The key file name carries the login user, which is how the
application picks the account it logs in as. This uses the Prompt Zero login user rather than
root, so no root login is re-enabled, and the sudoers line is upstream's requirement for that.

## 3. Secrets

Seven, all generated here: the instance id, the application key, the database and Redis
passwords, and three realtime credentials. Print none of them, and keep every one of them out of
your summary and your log lines. Hex, because two travel inside connection strings.

```bash
umask 077
cat > /data/coolify/source/.env <<EOF
APP_ID=$(openssl rand -hex 16)
APP_NAME=Coolify
AUTOUPDATE=false
APP_KEY=base64:$(openssl rand -base64 32)
DB_USERNAME=coolify
DB_DATABASE=coolify
DB_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
PUSHER_APP_ID=$(openssl rand -hex 32)
PUSHER_APP_KEY=$(openssl rand -hex 32)
PUSHER_APP_SECRET=$(openssl rand -hex 32)
EOF
umask 022
sudo chown "$(id -u)":9999 /data/coolify/source/.env
chmod 640 /data/coolify/source/.env
ls -l /data/coolify/source/.env
```

Assert: mode `-rw-r-----`, group `9999`. 640 rather than 600 is the one file this install widens:
the container reads it as uid 9999 and nothing else is in that group. `APP_KEY` encrypts the keys
the dashboard stores; `AUTOUPDATE=false` stops it replacing the pinned image itself.

## 4. compose.yml

```bash
cat > /data/coolify/source/compose.yml <<'EOF'
# Coolify · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   manual install ..... https://coolify.io/docs/get-started/installation
#   ports and firewall . https://coolify.io/docs/knowledge-base/server/firewall
#   host connection .... https://coolify.io/docs/knowledge-base/server/openssh
#   proxy choices ...... https://coolify.io/docs/knowledge-base/server/proxies
#
# Four services: the application, its PostgreSQL, its Redis, and the realtime
# server behind the dashboard's live logs and web terminal. Container names, the
# network name and the /data/coolify paths are strings the application looks up
# by hand. Three loopback ports: 8115 dashboard, 6001 realtime, 6002 terminal;
# this server's proxy is Custom (None), so none competes with Caddy for 80 and
# 443. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  coolify:
    image: ghcr.io/coollabsio/coolify:4.1.2@sha256:3a27ba5f7f98ff7763a0a4d6715ec36e564f9622eea8f492c46f90716ea2525f
    container_name: coolify
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    env_file: /data/coolify/source/.env
    volumes:
      - /data/coolify/source/.env:/var/www/html/.env:ro
      - /data/coolify/ssh:/var/www/html/storage/app/ssh
      - /data/coolify/applications:/var/www/html/storage/app/applications
      - /data/coolify/databases:/var/www/html/storage/app/databases
      - /data/coolify/services:/var/www/html/storage/app/services
      - /data/coolify/backups:/var/www/html/storage/app/backups
    ports:
      # Loopback only, like 6001 and 6002 below. 5432 and 6379 stay inside.
      - "127.0.0.1:8115:8080"
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/health || exit 1"]
      interval: 10s
      retries: 30
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      soketi:
        condition: service_started

  postgres:
    image: postgres:15.18-alpine@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f
    container_name: coolify-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: coolify
    volumes:
      - coolify-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U coolify -d coolify"]
      interval: 10s
      retries: 30

  redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    container_name: coolify-redis
    restart: unless-stopped
    command: ["redis-server", "--save", "20", "1", "--requirepass", "${REDIS_PASSWORD}"]
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - coolify-redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
      interval: 10s
      retries: 30

  soketi:
    image: ghcr.io/coollabsio/coolify-realtime:1.0.16@sha256:b5bb9d1c95d9b4ca59773b82d1e1a2bf4ccac5fbed33be19b9b3906574db3629
    container_name: coolify-realtime
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      SOKETI_DEFAULT_APP_ID: ${PUSHER_APP_ID}
      SOKETI_DEFAULT_APP_KEY: ${PUSHER_APP_KEY}
      SOKETI_DEFAULT_APP_SECRET: ${PUSHER_APP_SECRET}
    volumes:
      - /data/coolify/ssh:/var/www/html/storage/app/ssh
    ports:
      - "127.0.0.1:6001:6001"
      - "127.0.0.1:6002:6002"

networks:
  default:
    name: coolify
    external: true

volumes:
  coolify-db:
    name: coolify-db
  coolify-redis:
    name: coolify-redis
EOF
cd /data/coolify/source && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Compose reads .env from the project directory for the
`${...}` values; the application reads it again inside the container.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy first: a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-coolify
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Coolify · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://coolify.io/docs/knowledge-base/server/proxies and
# https://caddyserver.com/docs/automatic-https
#
# Three routes, because one hostname fronts three services, and this is the
# table upstream's own proxy writes for an instance with a domain. Drop either
# socket route and the dashboard loads while its live logs and web terminal
# never connect. Append it to /etc/caddy/Caddyfile with <DOMAIN> replaced by
# the hostname pointed at this box.

<DOMAIN> {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# Upstream's rules are PathPrefix(`/app`) for live logs and
	# PathPrefix(`/terminal/ws`) for the web terminal. These are those.
	reverse_proxy /app* 127.0.0.1:6001
	reverse_proxy /terminal/ws* 127.0.0.1:6002

	# Everything else is the dashboard. No loopback port is in the firewall.
	reverse_proxy 127.0.0.1:8115
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. On failure restore /etc/caddy/Caddyfile.before-coolify, reload, and report
what it objected to. Caddy gets the certificate on the first request and renews it itself.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a Prompt Zero box:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge, 443/tcp is the way in, 443/udp is HTTP/3. 8115, 6001 and 6002
stay closed because compose binds them to 127.0.0.1, 5432 and 6379 because compose never
publishes them. Upstream opens the first three for a dashboard reached by IP and says they can
close behind a domain, and one is in front here from the first request. Assert: `Status: active`,
rules for 80, 443/tcp and 443/udp, none for the five.

## 7. Start and verify

```bash
cd /data/coolify/source
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/health; echo
curl -sSL -o /dev/null -w '%{url_effective}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/register | grep -c 'Create your account'
docker compose ps
```

Assert all five, printing what you got. The loop ends on `200`. `/api/health` answers the single
word `OK`. The root lands on `https://<DOMAIN>/register`, where an instance with no users sends
everyone. The grep prints at least `1`: that screen carries the heading `Coolify` above the line
`Create your account`. `ps` shows four containers up. On any miss, stop, run
`docker compose logs --tail 40 coolify` and name the cause: an unhealthy database is step 3 with
an empty `DB_PASSWORD`, a `502` is step 5. A running container is not success.

STOP: tell the user to open https://<DOMAIN> and create their account there. It is the only
moment it can be made and no mail server here can reset it, so have them save the password in a
manager first. Do not continue until they confirm.

```bash
curl -sSL -o /dev/null -w '%{url_effective}\n' https://<DOMAIN>/register
```

Assert: `https://<DOMAIN>/login`. Registration closes once the first user exists.

STOP: tell the user to do three things in the dashboard. In Settings, set the instance's domain
to `https://<DOMAIN>`. In Servers, open `localhost`, go to Proxy, choose `Custom (None)` until
the page reads `Custom (None) Proxy Selected`, then press Validate and confirm it reports
reachable. Do not continue until they confirm all three.

```bash
docker ps -a --filter name=coolify-proxy --format '{{.Names}} {{.Status}}'
```

Assert: nothing, or a container that is `Exited`. A running `coolify-proxy` means Traefik is
still selected and still trying to take 80 and 443. Every assert here passes first.

## 8. First backup and restore

Two artifacts: the database holds every project, server, key and deployment record, the archive
what rebuilds the service around it, host key included.

```bash
cd /data/coolify/source
docker compose exec -T postgres pg_dump -U coolify -d coolify | gzip > /srv/coolify/backups/coolify-db-$(date +%F).sql.gz
sudo tar -czf /srv/coolify/backups/coolify-config-$(date +%F).tar.gz -C /data/coolify source ssh -C /etc/caddy Caddyfile
ls -lh /srv/coolify/backups/
```

Assert: both exist, both non-empty, print both sizes. Nothing goes down: `pg_dump` snapshots a
running database. A backup on the same disk is not a backup, so run this from the user's
machine:

```bash
mkdir -p ~/backups/coolify
scp vps:/srv/coolify/backups/* ~/backups/coolify/
```

To restore: `docker compose down`, untar the config archive back into /data/coolify so
source/.env and the host key land first, `docker compose up -d postgres`, wait for healthy, pipe
`gunzip -c` on the `.sql.gz` into `docker compose exec -T postgres psql -U coolify -d coolify`,
then `docker compose up -d`. The stakes: those rows are encrypted with `APP_KEY` from that
`.env`, so a dump restored without the archive comes back unreadable.

## 9. Updating later

Versions are listed at https://github.com/coollabsio/coolify/releases, and the realtime image
pairing with each is named in `versions.json` at that tag. Back up first, then edit the two
image lines in compose.yml to the new tags and digests:

```bash
cd /data/coolify/source
docker compose pull
docker compose up -d
docker compose logs --tail 40 coolify
```

It migrates its own database on the way up, so watch that log settle, then re-run step 7's
health check. `AUTOUPDATE=false` keeps the dashboard's update button from doing this itself.

## 10. What will probably go wrong

The first boot fails at a proxy nobody asked for. A fresh instance seeds its own server entry
with Traefik selected and starts it at once, and Traefik wants 80 and 443, which Caddy holds. I
spent ten minutes reading a red `Bind for 0.0.0.0:80 failed: port is already allocated` before I
understood it was correct: Docker refused a second proxy those ports. `Custom (None)` ends it.

## 11. Out of scope

- Do not select Traefik or Caddy as this server's proxy, and do not stop the host Caddy to make
  room for one. Applications deployed here get a loopback port and a site block, as this did.
- Do not run upstream's one-line installer on this box. It fetches its own compose files, writes
  its own .env and installs a root login, and this install has done that work already.
- Do not configure SMTP, Slack or any other notification transport, add an S3 backup
  destination, or connect a GitHub App. Each is a separate credential and none is needed to get
  one application running by hand, which is what should happen first.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Coolify 4.1.2 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the box.

Read this before step 1. Coolify is not an application that sits in its own directory. It holds a
private key that logs into this host and drives its Docker daemon, so from first start it can
build, run and delete containers on the server you rent. That is the product. It is also the
reason this install puts that key behind your own login user rather than a root login.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail / | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `30` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a hostname that does
not resolve and failed attempts count against a rate limit you cannot see. Under 30 GB free is
the one to take seriously here rather than shrug at: this box will be building other people's
applications, and Docker's image layers are where the space goes.

## 2. Layout and host access

The application writes deployment files to /data/coolify by name on the machine it manages, so
that path is not yours to move. Your backup archives go outside it, in /srv/coolify/backups.

```bash
sudo install -d -m 750 -o "$(id -u)" -g 9999 /data/coolify /data/coolify/source
sudo install -d -m 700 -o 9999 -g 9999 /data/coolify/{ssh,ssh/keys,ssh/mux,applications,databases,services,backups,proxy,proxy/dynamic}
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" /srv/coolify /srv/coolify/backups
KEY=/data/coolify/ssh/keys/id.$(id -un)@host.docker.internal
sudo ssh-keygen -t ed25519 -a 100 -N "" -C coolify -q -f "$KEY"
sudo cat "$KEY.pub" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
sudo rm -f "$KEY.pub"
sudo chown 9999:9999 "$KEY" && sudo chmod 600 "$KEY"
printf '%s ALL=(ALL) %s ALL\n' "$(id -un)" 'NOPASSWD:' | sudo tee /etc/sudoers.d/coolify >/dev/null
sudo chmod 440 /etc/sudoers.d/coolify
sudo visudo -c -f /etc/sudoers.d/coolify
docker network create --attachable coolify || true
ls -la /data/coolify
```

You should see: `/etc/sudoers.d/coolify: parsed OK`, a network id from `docker network create`,
and a listing where `source` belongs to you and everything else is mode `drwx------` owned by
`9999`, the uid the container runs as.

If you do not: `parsed OK` missing means stop and fix that file before you log out, because a
broken sudoers file can lock you out of sudo entirely; `sudo rm /etc/sudoers.d/coolify` from the
session you still have open is the way back. The key file name is not cosmetic: the application
reads the user name out of it and logs in as that account, so renaming the file changes who it
tries to be. `network with name coolify already exists` is fine, that is what the `|| true` is
for. And understand what the sudoers line does before you paste it: it makes that key root on
this machine. Upstream's own installer achieves the same thing by enabling a root login instead.

## 3. Secrets

Seven values, all generated on the server: the instance id, the application key, the database
password, the Redis password and three realtime credentials.

```bash
umask 077
cat > /data/coolify/source/.env <<EOF
APP_ID=$(openssl rand -hex 16)
APP_NAME=Coolify
AUTOUPDATE=false
APP_KEY=base64:$(openssl rand -base64 32)
DB_USERNAME=coolify
DB_DATABASE=coolify
DB_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
PUSHER_APP_ID=$(openssl rand -hex 32)
PUSHER_APP_KEY=$(openssl rand -hex 32)
PUSHER_APP_SECRET=$(openssl rand -hex 32)
EOF
umask 022
sudo chown "$(id -u)":9999 /data/coolify/source/.env
chmod 640 /data/coolify/source/.env
ls -l /data/coolify/source/.env
```

You should see: mode `-rw-r-----`, your own username, and group `9999`.

If you do not: `-rw-r--r--` means `umask 077` did not take, which happens if you pasted the lines
into different shells; run `chmod 640` again. 640 rather than 600 is deliberate, because the
container reads this file as uid 9999 and nothing else on the box is in that group. If the file
already existed from an earlier attempt, this block has now replaced every secret in it, which is
harmless before the database exists and a problem afterwards: PostgreSQL keeps the password it
was created with, so a changed `DB_PASSWORD` on an existing volume shows up as an authentication
failure in the application log rather than as anything about passwords.

Do not paste that file, any value from it, or any command output containing one into this chat
window. `APP_KEY` is the one that matters most: it encrypts the private keys and registry
credentials the dashboard will store, and a database backup restored without it is unreadable.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /data/coolify/source/compose.yml <<'EOF'
# Coolify · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   manual install ..... https://coolify.io/docs/get-started/installation
#   ports and firewall . https://coolify.io/docs/knowledge-base/server/firewall
#   host connection .... https://coolify.io/docs/knowledge-base/server/openssh
#   proxy choices ...... https://coolify.io/docs/knowledge-base/server/proxies
#
# Four services: the application, its PostgreSQL, its Redis, and the realtime
# server behind the dashboard's live logs and web terminal. Container names, the
# network name and the /data/coolify paths are strings the application looks up
# by hand. Three loopback ports: 8115 dashboard, 6001 realtime, 6002 terminal;
# this server's proxy is Custom (None), so none competes with Caddy for 80 and
# 443. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  coolify:
    image: ghcr.io/coollabsio/coolify:4.1.2@sha256:3a27ba5f7f98ff7763a0a4d6715ec36e564f9622eea8f492c46f90716ea2525f
    container_name: coolify
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    env_file: /data/coolify/source/.env
    volumes:
      - /data/coolify/source/.env:/var/www/html/.env:ro
      - /data/coolify/ssh:/var/www/html/storage/app/ssh
      - /data/coolify/applications:/var/www/html/storage/app/applications
      - /data/coolify/databases:/var/www/html/storage/app/databases
      - /data/coolify/services:/var/www/html/storage/app/services
      - /data/coolify/backups:/var/www/html/storage/app/backups
    ports:
      # Loopback only, like 6001 and 6002 below. 5432 and 6379 stay inside.
      - "127.0.0.1:8115:8080"
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/health || exit 1"]
      interval: 10s
      retries: 30
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      soketi:
        condition: service_started

  postgres:
    image: postgres:15.18-alpine@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f
    container_name: coolify-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: coolify
    volumes:
      - coolify-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U coolify -d coolify"]
      interval: 10s
      retries: 30

  redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    container_name: coolify-redis
    restart: unless-stopped
    command: ["redis-server", "--save", "20", "1", "--requirepass", "${REDIS_PASSWORD}"]
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - coolify-redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
      interval: 10s
      retries: 30

  soketi:
    image: ghcr.io/coollabsio/coolify-realtime:1.0.16@sha256:b5bb9d1c95d9b4ca59773b82d1e1a2bf4ccac5fbed33be19b9b3906574db3629
    container_name: coolify-realtime
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      SOKETI_DEFAULT_APP_ID: ${PUSHER_APP_ID}
      SOKETI_DEFAULT_APP_KEY: ${PUSHER_APP_KEY}
      SOKETI_DEFAULT_APP_SECRET: ${PUSHER_APP_SECRET}
    volumes:
      - /data/coolify/ssh:/var/www/html/storage/app/ssh
    ports:
      - "127.0.0.1:6001:6001"
      - "127.0.0.1:6002:6002"

networks:
  default:
    name: coolify
    external: true

volumes:
  coolify-db:
    name: coolify-db
  coolify-redis:
    name: coolify-redis
EOF
cd /data/coolify/source && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /data/coolify/source/.env not found` means step 3 did not write the
file. `network coolify declared as external, but could not be found` means the
`docker network create` line in step 2 did not run. `services must be a mapping` means the
indentation was lost between the page and your terminal: run `rm /data/coolify/source/compose.yml`
and paste again in one go.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-coolify
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Coolify · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://coolify.io/docs/knowledge-base/server/proxies and
# https://caddyserver.com/docs/automatic-https
#
# Three routes, because one hostname fronts three services, and this is the
# table upstream's own proxy writes for an instance with a domain. Drop either
# socket route and the dashboard loads while its live logs and web terminal
# never connect. Append it to /etc/caddy/Caddyfile with <DOMAIN> replaced by
# the hostname pointed at this box.

<DOMAIN> {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# Upstream's rules are PathPrefix(`/app`) for live logs and
	# PathPrefix(`/terminal/ws`) for the web terminal. These are those.
	reverse_proxy /app* 127.0.0.1:6001
	reverse_proxy /terminal/ws* 127.0.0.1:6002

	# Everything else is the dashboard. No loopback port is in the firewall.
	reverse_proxy 127.0.0.1:8115
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-coolify /etc/caddy/Caddyfile`, reload, and
paste again. Three routes for one hostname is not a mistake: the dashboard is on 8115, its live
deployment logs come over a socket on 6001, and its web terminal over another on 6002. Upstream's
own proxy splits the same hostname the same way. If you drop either socket line, the dashboard
will load and its logs will spin forever with no error anywhere.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8115`, `6001`, `6002`, `5432` or `6379`.

If you do not: delete anything for those five with `sudo ufw delete allow 8115` and so on. All
three application ports are bound to 127.0.0.1 by the compose file and the two database ports are
never published, so none of them has a host port a firewall rule could apply to. Upstream's
firewall page tells you to open 8000, 6001 and 6002, and then says you can close them once you
reach the dashboard on a custom domain. You are reaching it on a custom domain from the first
request, so they never open here. `Status: inactive` is a different problem: Prompt Zero left this
firewall enabled, so something has turned it off, and `sudo ufw enable` puts it back.

## 7. Start and verify

```bash
cd /data/coolify/source
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/health; echo
curl -sSL -o /dev/null -w '%{url_effective}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/register | grep -c 'Create your account'
docker compose ps
```

You should see, in order: the loop reaching `200`, the single word `OK`, then
`https://<DOMAIN>/register`, then a count of at least `1`, then four containers listed.

If you do not: the pull alone can take several minutes and the application migrates its own
database on the way up, so let the loop run all forty times before deciding anything is broken.
If it never reaches `200`, run `docker compose logs --tail 20 postgres` first, because a database
that never reports healthy is step 3 with an empty `DB_PASSWORD`, and
`docker compose logs --tail 40 coolify` second. A `502` from Caddy with healthy containers means
step 5 is pointing at the wrong port. A running container is not success.

Now open https://<DOMAIN> in a browser. The first screen carries the heading `Coolify` above the
line `Create your account`, with a `Root User Setup` notice explaining that this account will have
full admin access. Create it. This is the only moment it can be made, and there is no mail server
here to reset the password, so put it in your password manager before you submit the form.

```bash
curl -sSL -o /dev/null -w '%{url_effective}\n' https://<DOMAIN>/register
```

You should see: `https://<DOMAIN>/login`.

If you do not: still seeing `/register` means the account was not created. Registration closes
itself the moment the first user exists, and that is the whole of the signup security model here.

Three things left, all in the dashboard, and the middle one is the important one. In Settings, set
the instance's domain to `https://<DOMAIN>`. In Servers, open `localhost`, go to Proxy and choose
`Custom (None)` until the page reads `Custom (None) Proxy Selected`. Then press Validate on that
same server and confirm it reports reachable. Then:

```bash
docker ps -a --filter name=coolify-proxy --format '{{.Names}} {{.Status}}'
```

You should see: nothing at all, or a single line ending `Exited`.

If you do not: a `coolify-proxy` with an `Up` status means Traefik is still selected for this
server and is competing with Caddy for ports 80 and 443. Go back to the Proxy tab and pick
`Custom (None)`. If Validate reports the server unreachable instead, the key chain from step 2 is
the cause: check that `~/.ssh/authorized_keys` has a line ending in `coolify` and that the file
under /data/coolify/ssh/keys is owned by `9999`.

## 8. First backup and restore

Two artifacts. The database holds every project, server, key and deployment record. The config
archive holds what rebuilds the service around it, the host key included.

```bash
cd /data/coolify/source
docker compose exec -T postgres pg_dump -U coolify -d coolify | gzip > /srv/coolify/backups/coolify-db-$(date +%F).sql.gz
sudo tar -czf /srv/coolify/backups/coolify-config-$(date +%F).tar.gz -C /data/coolify source ssh -C /etc/caddy Caddyfile
ls -lh /srv/coolify/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline, because
`pg_dump` snapshots a running database.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/coolify
scp vps:/srv/coolify/backups/* ~/backups/coolify/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/coolify/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.

To restore: `docker compose down`, untar the config archive back into /data/coolify so
source/.env and the host key land before anything starts, `docker compose up -d postgres`, wait
for it to report healthy, then pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U coolify -d coolify`, then `docker compose up -d`. Know
the stakes: those rows are encrypted with `APP_KEY` from that `.env`, so a database restored
without the config archive comes back with credentials nobody can read. The two files travel
together or neither of them is a backup.

## 9. Updating later

Versions are listed at https://github.com/coollabsio/coolify/releases, and the realtime image
that pairs with each is named in `versions.json` at that tag. Take both backup artifacts first,
then edit the two `image:` lines in /data/coolify/source/compose.yml to the new tags and digests.

```bash
cd /data/coolify/source
docker compose pull
docker compose up -d
docker compose logs --tail 40 coolify
```

You should see: migration output, then the application starting, and no repeating restart.

If you do not: put the old tags and digests back and run the same three commands. `AUTOUPDATE` is
`false` in your .env, which is why the dashboard's own update button will not do this behind your
back; that is the point of pinning a digest, and it is also why nobody will apply a security fix
here except you.

## 10. What will probably go wrong

The first boot fails at a proxy nobody asked for. A fresh instance seeds its own server entry with
Traefik selected and tries to start it straight away, and Traefik wants 80 and 443, which Caddy
already holds. I spent ten minutes reading a red `Bind for 0.0.0.0:80 failed: port is already
allocated` before I understood it was the correct outcome and not a broken install: Docker refused
a second proxy those ports. Choosing `Custom (None)` in step 7 ends the attempts.

## 11. Out of scope

- Do not select Traefik or Caddy as this server's proxy, and do not stop the host Caddy to make
  room for one. Applications deployed here get a loopback port and a site block in
  /etc/caddy/Caddyfile, the same way this dashboard did.
- Do not run upstream's one-line installer on this box. It fetches its own compose files, writes
  its own .env and installs a root login, and this install has done that work already.
- Do not configure SMTP, Slack or any other notification transport, and do not add an S3 backup
  destination. Each is a separate credential and none is needed to deploy.
- Do not connect a GitHub App or enable automatic deployments yet. Get one application running
  by hand first, so the next failure has one cause instead of two.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Coolify 4.1.2 and the PostgreSQL, Redis and realtime server it needs under
~/selfhost/coolify, answering at http://localhost:8115.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. Coolify deploys by logging into the machine it manages over a remote login channel, and
this prompt does not open one into the user's own computer, so the dashboard runs here and the
server entry it makes stays unreachable. They get the interface and the catalogue to learn on.
Nothing deploys from here; if that is not what they wanted, stop.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print next, for step 2. Four containers want 2048 MB of RAM
available and 30 GB free on the home disk, and all four images publish amd64 and arm64. On macOS
and Windows that memory is the host's, and Docker Desktop takes its share out of it. If available
RAM is under 2048 MB or free disk under 30 GB, print both and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/coolify/backups
ls -la ~/selfhost/coolify
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: the account and
the projects are rows in PostgreSQL, which step 5 keeps in a Docker volume.

## 4. Secrets

Seven, all generated here: the instance id, the application key, the database and Redis
passwords, and three realtime credentials. Print none of them and keep them out of your summary.
Hex, because two travel inside connection strings.

```bash
umask 077
cat > ~/selfhost/coolify/.env <<EOF
APP_ID=$(openssl rand -hex 16)
APP_NAME=Coolify
AUTOUPDATE=false
APP_KEY=base64:$(openssl rand -base64 32)
DB_USERNAME=coolify
DB_DATABASE=coolify
DB_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
PUSHER_APP_ID=$(openssl rand -hex 32)
PUSHER_APP_KEY=$(openssl rand -hex 32)
PUSHER_APP_SECRET=$(openssl rand -hex 32)
EOF
umask 022
chmod 640 ~/selfhost/coolify/.env
if [ "$(uname -s)" = "Linux" ]; then sudo chown "$(id -u)":9999 ~/selfhost/coolify/.env; fi
ls -l ~/selfhost/coolify/.env
```

Assert: mode `-rw-r-----`. Git Bash ships openssl, so these run the same on all three systems.
640 rather than 600 is deliberate: the container reads this file as uid 9999. On Windows those
mode bits are advisory and the real boundary is the user's own account.

## 5. compose.yml

```bash
cat > ~/selfhost/coolify/compose.yml <<'EOF'
# Coolify · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   manual install ..... https://coolify.io/docs/get-started/installation
#   ports and firewall . https://coolify.io/docs/knowledge-base/server/firewall
#   host connection .... https://coolify.io/docs/knowledge-base/server/openssh
#   proxy choices ...... https://coolify.io/docs/knowledge-base/server/proxies
#
# Four services on the computer you are sitting at, every path relative to
# ~/selfhost/coolify/ so one file works on macOS, Linux and Windows. PostgreSQL
# and Redis keep their data in named volumes, as upstream does, because both
# chown their data directory to a uid of their own choosing and a bind mount in
# a home directory cannot allow that on Windows. Digests read 2026-08-06.
#
# Read this first: here the dashboard runs and the server entry it makes for
# this computer stays unreachable, because the application reaches what it
# deploys to over a remote login channel this path does not open. Nothing
# deploys from here, which is why the storage directories the server file
# mounts are absent.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  coolify:
    image: ghcr.io/coollabsio/coolify:4.1.2@sha256:3a27ba5f7f98ff7763a0a4d6715ec36e564f9622eea8f492c46f90716ea2525f
    container_name: coolify
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    env_file: ./.env
    volumes:
      - ./.env:/var/www/html/.env:ro
    ports:
      - "127.0.0.1:8115:8080"
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/health || exit 1"]
      interval: 10s
      retries: 30
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      soketi:
        condition: service_started

  postgres:
    image: postgres:15.18-alpine@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f
    container_name: coolify-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: coolify
    volumes:
      - coolify-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U coolify -d coolify"]
      interval: 10s
      retries: 30

  redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    container_name: coolify-redis
    restart: unless-stopped
    command: ["redis-server", "--save", "20", "1", "--requirepass", "${REDIS_PASSWORD}"]
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - coolify-redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
      interval: 10s
      retries: 30

  soketi:
    image: ghcr.io/coollabsio/coolify-realtime:1.0.16@sha256:b5bb9d1c95d9b4ca59773b82d1e1a2bf4ccac5fbed33be19b9b3906574db3629
    container_name: coolify-realtime
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      SOKETI_DEFAULT_APP_ID: ${PUSHER_APP_ID}
      SOKETI_DEFAULT_APP_KEY: ${PUSHER_APP_KEY}
      SOKETI_DEFAULT_APP_SECRET: ${PUSHER_APP_SECRET}
    ports:
      - "127.0.0.1:6001:6001"
      - "127.0.0.1:6002:6002"

networks:
  default:
    name: coolify

volumes:
  coolify-db:
    name: coolify-db
  coolify-redis:
    name: coolify-redis
EOF
cd ~/selfhost/coolify && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Four services, three published ports, two named volumes.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no
hostname, so nothing to resolve. A certificate attests a public name and nothing here has one;
browsers treat http://localhost as a secure context anyway, so pages needing crypto still work.
Nothing is published beyond loopback, so no port needs closing: 8115, 6001 and 6002 bind to
127.0.0.1, not the user's phone, not a laptop on the same wifi, not anyone. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/coolify/compose.yml
```

Assert: `3`. PostgreSQL and Redis publish no host port, so 5432 and 6379 cannot appear.

## 7. Start and verify

It migrates its own database on the way up, so a cold start takes minutes.

```bash
cd ~/selfhost/coolify
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8115/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8115/api/health; echo
curl -sSL -o /dev/null -w '%{url_effective}\n' http://localhost:8115/
curl -sSL http://localhost:8115/register | grep -c 'Create your account'
docker compose ps
```

Assert all five, printing what you got. The loop ends on `200`. `/api/health` answers the single
word `OK`. The root lands on `http://localhost:8115/register`, where an instance with no users
sends everyone. The grep prints at least `1`: that screen carries `Coolify` above the line
`Create your account`. `ps` shows four containers up. On any miss stop, run
`docker compose logs --tail 40 coolify`, and name the cause: an unhealthy database is step 4 with
an empty `DB_PASSWORD`, and `port is already allocated` means something else holds 8115, 6001 or
6002. A container is not success.

STOP: tell the user to open http://localhost:8115 and create their account there. It is the only
moment it can be made and no mail server here can reset it, so have them save the password first.
Do not continue until they confirm.

```bash
curl -sSL -o /dev/null -w '%{url_effective}\n' http://localhost:8115/register
```

Assert: `http://localhost:8115/login`. Registration closes once the first user exists. Tell the
user what they will see next: a server named `localhost`, marked unreachable. That is step 1's
warning on screen, not a fault to chase.

## 8. First backup and restore

Two artifacts: the database holds the account and every project record, the archive the two
files that rebuild the service.

```bash
cd ~/selfhost/coolify
docker compose exec -T postgres pg_dump -U coolify -d coolify | gzip > ~/selfhost/coolify/backups/coolify-db-$(date +%F).sql.gz
tar -C ~/selfhost/coolify -czf ~/selfhost/coolify/backups/coolify-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/coolify/backups/
```

Assert: both exist and are non-empty. Print both sizes. Nothing goes down: `pg_dump` snapshots
a running database.

Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a folder
their sync service watches or a USB stick, and copy both there with `cp`; in Git Bash a Windows
drive is `/d/Backups`. Assert: the user confirms both are listed there, or say plainly that this
install has no backup.

To restore, in this order. `cd ~/selfhost/coolify` and untar the config archive there first, so
compose.yml and .env are back before any container starts: PostgreSQL takes `DB_PASSWORD` from
.env the moment it initialises an empty volume. Then `docker compose down -v`, the one place
`-v` belongs, `docker compose up -d postgres`, wait about 30 seconds for healthy, pipe
`gunzip -c` on the `.sql.gz` into `docker compose exec -T postgres psql -U coolify -d coolify`,
then `docker compose up -d`, then log in once to prove it. Those rows are encrypted with
`APP_KEY` from that `.env`, so the two files travel together or neither is a backup.

## 9. Updating later

Versions are listed at https://github.com/coollabsio/coolify/releases, and the realtime image
pairing with each is in `versions.json` at that tag. Back up first, then edit the two image
lines in compose.yml:

```bash
cd ~/selfhost/coolify
docker compose pull
docker compose up -d
docker compose logs --tail 40 coolify
```

Watch that log settle, then re-run step 7's check before calling it done.

## 10. What will probably go wrong

I rebooted this machine, opened http://localhost:8115 and got a connection refused that reads
like a lost install. It was not: Docker Desktop had not started with the session, so nothing was
listening on 8115, and `restart: unless-stopped` only acts once the Docker daemon is up. Turn on
its start-at-login setting, then run `docker compose up -d` in ~/selfhost/coolify after a reboot
before concluding anything is broken.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not open a remote login service on this computer, and do not add this machine as a server
  in the dashboard. That hands a container a key to the user's own account, a trade worth making
  on a rented box and not on this one.
- Do not select Traefik or Caddy as a proxy in the dashboard, do not configure SMTP, and do not
  connect a GitHub App. All three are work handed to an install that cannot deploy.
````

## docker-compose.yml

```yaml
# Coolify · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   manual install ..... https://coolify.io/docs/get-started/installation
#   ports and firewall . https://coolify.io/docs/knowledge-base/server/firewall
#   host connection .... https://coolify.io/docs/knowledge-base/server/openssh
#   proxy choices ...... https://coolify.io/docs/knowledge-base/server/proxies
#
# Four services: the application, its PostgreSQL, its Redis, and the realtime
# server behind the dashboard's live logs and web terminal. Container names, the
# network name and the /data/coolify paths are strings the application looks up
# by hand. Three loopback ports: 8115 dashboard, 6001 realtime, 6002 terminal;
# this server's proxy is Custom (None), so none competes with Caddy for 80 and
# 443. Digests read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  coolify:
    image: ghcr.io/coollabsio/coolify:4.1.2@sha256:3a27ba5f7f98ff7763a0a4d6715ec36e564f9622eea8f492c46f90716ea2525f
    container_name: coolify
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    env_file: /data/coolify/source/.env
    volumes:
      - /data/coolify/source/.env:/var/www/html/.env:ro
      - /data/coolify/ssh:/var/www/html/storage/app/ssh
      - /data/coolify/applications:/var/www/html/storage/app/applications
      - /data/coolify/databases:/var/www/html/storage/app/databases
      - /data/coolify/services:/var/www/html/storage/app/services
      - /data/coolify/backups:/var/www/html/storage/app/backups
    ports:
      # Loopback only, like 6001 and 6002 below. 5432 and 6379 stay inside.
      - "127.0.0.1:8115:8080"
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/health || exit 1"]
      interval: 10s
      retries: 30
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      soketi:
        condition: service_started

  postgres:
    image: postgres:15.18-alpine@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f
    container_name: coolify-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: coolify
    volumes:
      - coolify-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U coolify -d coolify"]
      interval: 10s
      retries: 30

  redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    container_name: coolify-redis
    restart: unless-stopped
    command: ["redis-server", "--save", "20", "1", "--requirepass", "${REDIS_PASSWORD}"]
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - coolify-redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
      interval: 10s
      retries: 30

  soketi:
    image: ghcr.io/coollabsio/coolify-realtime:1.0.16@sha256:b5bb9d1c95d9b4ca59773b82d1e1a2bf4ccac5fbed33be19b9b3906574db3629
    container_name: coolify-realtime
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      SOKETI_DEFAULT_APP_ID: ${PUSHER_APP_ID}
      SOKETI_DEFAULT_APP_KEY: ${PUSHER_APP_KEY}
      SOKETI_DEFAULT_APP_SECRET: ${PUSHER_APP_SECRET}
    volumes:
      - /data/coolify/ssh:/var/www/html/storage/app/ssh
    ports:
      - "127.0.0.1:6001:6001"
      - "127.0.0.1:6002:6002"

networks:
  default:
    name: coolify
    external: true

volumes:
  coolify-db:
    name: coolify-db
  coolify-redis:
    name: coolify-redis
```

## compose.local.yml

```yaml
# Coolify · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   manual install ..... https://coolify.io/docs/get-started/installation
#   ports and firewall . https://coolify.io/docs/knowledge-base/server/firewall
#   host connection .... https://coolify.io/docs/knowledge-base/server/openssh
#   proxy choices ...... https://coolify.io/docs/knowledge-base/server/proxies
#
# Four services on the computer you are sitting at, every path relative to
# ~/selfhost/coolify/ so one file works on macOS, Linux and Windows. PostgreSQL
# and Redis keep their data in named volumes, as upstream does, because both
# chown their data directory to a uid of their own choosing and a bind mount in
# a home directory cannot allow that on Windows. Digests read 2026-08-06.
#
# Read this first: here the dashboard runs and the server entry it makes for
# this computer stays unreachable, because the application reaches what it
# deploys to over a remote login channel this path does not open. Nothing
# deploys from here, which is why the storage directories the server file
# mounts are absent.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  coolify:
    image: ghcr.io/coollabsio/coolify:4.1.2@sha256:3a27ba5f7f98ff7763a0a4d6715ec36e564f9622eea8f492c46f90716ea2525f
    container_name: coolify
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    env_file: ./.env
    volumes:
      - ./.env:/var/www/html/.env:ro
    ports:
      - "127.0.0.1:8115:8080"
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/health || exit 1"]
      interval: 10s
      retries: 30
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      soketi:
        condition: service_started

  postgres:
    image: postgres:15.18-alpine@sha256:3d0f7584ed7d04e27fa050d6683a74746608faf21f202be78460d679cc56461f
    container_name: coolify-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: coolify
    volumes:
      - coolify-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U coolify -d coolify"]
      interval: 10s
      retries: 30

  redis:
    image: redis:7.4.10-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
    container_name: coolify-redis
    restart: unless-stopped
    command: ["redis-server", "--save", "20", "1", "--requirepass", "${REDIS_PASSWORD}"]
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - coolify-redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
      interval: 10s
      retries: 30

  soketi:
    image: ghcr.io/coollabsio/coolify-realtime:1.0.16@sha256:b5bb9d1c95d9b4ca59773b82d1e1a2bf4ccac5fbed33be19b9b3906574db3629
    container_name: coolify-realtime
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      SOKETI_DEFAULT_APP_ID: ${PUSHER_APP_ID}
      SOKETI_DEFAULT_APP_KEY: ${PUSHER_APP_KEY}
      SOKETI_DEFAULT_APP_SECRET: ${PUSHER_APP_SECRET}
    ports:
      - "127.0.0.1:6001:6001"
      - "127.0.0.1:6002:6002"

networks:
  default:
    name: coolify

volumes:
  coolify-db:
    name: coolify-db
  coolify-redis:
    name: coolify-redis
```

## Caddyfile

```text
# Coolify · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://coolify.io/docs/knowledge-base/server/proxies and
# https://caddyserver.com/docs/automatic-https
#
# Three routes, because one hostname fronts three services, and this is the
# table upstream's own proxy writes for an instance with a domain. Drop either
# socket route and the dashboard loads while its live logs and web terminal
# never connect. Append it to /etc/caddy/Caddyfile with <DOMAIN> replaced by
# the hostname pointed at this box.

<DOMAIN> {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# Upstream's rules are PathPrefix(`/app`) for live logs and
	# PathPrefix(`/terminal/ws`) for the web terminal. These are those.
	reverse_proxy /app* 127.0.0.1:6001
	reverse_proxy /terminal/ws* 127.0.0.1:6002

	# Everything else is the dashboard. No loopback port is in the firewall.
	reverse_proxy 127.0.0.1:8115
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Coolify · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=deploy.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://coolify.io/docs/get-started/installation
#   https://coolify.io/docs/knowledge-base/server/openssh
#   https://coolify.io/docs/knowledge-base/server/proxies
#   https://coolify.io/docs/knowledge-base/server/firewall
#
# Read this part before you run it. Coolify manages Docker on the machine it
# deploys to, and it reaches that machine over SSH, even when the machine is the
# one it is installed on. So this script generates a key, puts the public half
# in your own authorized_keys, gives the container the private half, and adds a
# passwordless sudo line for your account, which is upstream's documented
# requirement for a non-root user. The plain reading: that key is root on this
# box, and whoever reaches the dashboard reaches the box. Upstream's own
# installer arrives at the same place by enabling a root login instead.
#
# Seven secrets are generated here: the instance id, the application key, the
# database password, the Redis password and three realtime credentials. All go
# into /data/coolify/source/.env, mode 640 so the container's uid 9999 can read
# it, and none is ever printed.
#
# The server's proxy is left for you to set to Custom (None) in the dashboard,
# because the host's Caddy already owns 80 and 443. Step 7 of prompt.md says so
# and this script prints the same instruction at the end.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/data/coolify}"
BACKUP_DIR="${BACKUP_DIR:-/srv/coolify/backups}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. deploy.example.com"
[ "$(id -u)" -ne 0 ] || die "run this as your login user, not root. The key this creates is bound to that account."
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
command -v ssh-keygen >/dev/null 2>&1 || die "ssh-keygen is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; four services plus builds want 2048 MB"
avail_gb="$(df -BG --output=avail / | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 30 ] || die "only ${avail_gb} GB free on /; this install wants 30 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out, make the key, open the sudo path ------------------
#
# /data/coolify is not a choice: the application writes deployment files to that
# path by name on the machine it manages. Our archives live outside it.

sudo install -d -m 750 -o "$(id -u)" -g 9999 "$APP_DIR" "$APP_DIR/source"
sudo install -d -m 700 -o 9999 -g 9999 \
	"$APP_DIR/ssh" "$APP_DIR/ssh/keys" "$APP_DIR/ssh/mux" \
	"$APP_DIR/applications" "$APP_DIR/databases" "$APP_DIR/services" \
	"$APP_DIR/backups" "$APP_DIR/proxy" "$APP_DIR/proxy/dynamic"
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" /srv/coolify "$BACKUP_DIR"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/source/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/source/Caddyfile"

# The user name lives in the key's filename: the application reads it out of
# there to decide which account to log in as. Do not rename this file.
KEY="$APP_DIR/ssh/keys/id.$(id -un)@host.docker.internal"
if ! sudo test -f "$KEY"; then
	sudo ssh-keygen -t ed25519 -a 100 -N "" -C coolify -q -f "$KEY"
	install -d -m 700 "$HOME/.ssh"
	sudo cat "$KEY.pub" >> "$HOME/.ssh/authorized_keys"
	chmod 600 "$HOME/.ssh/authorized_keys"
	sudo rm -f "$KEY.pub"
	sudo chown 9999:9999 "$KEY"
	sudo chmod 600 "$KEY"
fi

printf '%s ALL=(ALL) %s ALL\n' "$(id -un)" 'NOPASSWD:' | sudo tee /etc/sudoers.d/coolify >/dev/null
sudo chmod 440 /etc/sudoers.d/coolify
sudo visudo -c -f /etc/sudoers.d/coolify || die "the sudoers drop-in did not parse. Remove /etc/sudoers.d/coolify now."

docker network create --attachable coolify >/dev/null 2>&1 || true

# --- 3. Generate the seven secrets, on the server ----------------------------
#
# Hex for the two that travel inside connection strings. Read them later with
#   sudo grep APP_KEY /data/coolify/source/.env

if [ ! -f "$APP_DIR/source/.env" ]; then
	umask 077
	cat > "$APP_DIR/source/.env" <<-ENVFILE
		APP_ID=$(openssl rand -hex 16)
		APP_NAME=Coolify
		AUTOUPDATE=false
		APP_KEY=base64:$(openssl rand -base64 32)
		DB_USERNAME=coolify
		DB_DATABASE=coolify
		DB_PASSWORD=$(openssl rand -hex 32)
		REDIS_PASSWORD=$(openssl rand -hex 32)
		PUSHER_APP_ID=$(openssl rand -hex 32)
		PUSHER_APP_KEY=$(openssl rand -hex 32)
		PUSHER_APP_SECRET=$(openssl rand -hex 32)
	ENVFILE
	umask 022
	sudo chown "$(id -u)":9999 "$APP_DIR/source/.env"
	chmod 640 "$APP_DIR/source/.env"
fi

cd "$APP_DIR/source"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------
#
# Three routes: the dashboard on 8115, live deployment logs on 6001, the web
# terminal on 6002. Upstream's own proxy splits the hostname the same way.

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-coolify"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/source/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and none of the five app ports is one of them -------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8115, 6001, 6002, 5432 and 6379 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The application migrates its own database on the way up, so a cold pull takes
# several minutes. That is what the loop below is for.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/health"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/health" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/api/health answered ${code:-nothing}. Check: docker compose logs --tail 40 coolify"

curl -sS "https://${DOMAIN_HOST}/api/health" | grep -q 'OK' \
	|| die "/api/health answered 200 without OK. Check: docker compose logs --tail 40 coolify"

# A fresh instance sends every visitor to the registration screen. Both of these
# are the same assert from two directions: the redirect target and the heading.
landing="$(curl -sSL -o /dev/null -w '%{url_effective}' "https://${DOMAIN_HOST}/" || true)"
[ "$landing" = "https://${DOMAIN_HOST}/register" ] \
	|| die "https://${DOMAIN_HOST}/ landed on ${landing}, not the registration screen. Stop and investigate."
curl -sSL "https://${DOMAIN_HOST}/register" | grep -q 'Create your account' \
	|| die "the first screen does not carry 'Create your account'. Check: docker compose logs --tail 40 coolify"

docker compose ps

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U coolify -d coolify | gzip > "$BACKUP_DIR/coolify-db-${STAMP}.sql.gz"
sudo tar -czf "$BACKUP_DIR/coolify-config-${STAMP}.tar.gz" -C "$APP_DIR" source ssh -C /etc/caddy Caddyfile
ls -lh "$BACKUP_DIR/"
[ -s "$BACKUP_DIR/coolify-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$BACKUP_DIR/coolify-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

cat <<-DONE

	Coolify is answering at https://${DOMAIN_HOST}/api/health

	  1. Open https://${DOMAIN_HOST} now. The first screen says "Create your
	     account" under the heading Coolify, with a Root User Setup notice.
	     Make that account there. It is the only moment it can be made:
	     registration closes itself once the first user exists, and there is
	     no mail server here to reset the password. Save it first.
	  2. Then three things in the dashboard, and the middle one matters most:
	       - Settings: set the instance's domain to https://${DOMAIN_HOST}
	       - Servers > localhost > Proxy: choose Custom (None), until the page
	         reads "Custom (None) Proxy Selected". Traefik and Caddy both want
	         ports 80 and 443, which the host's Caddy already holds. Until you
	         set this, Coolify keeps trying to start a proxy and failing.
	       - Servers > localhost: press Validate and confirm it is reachable.
	     Confirm with:
	       docker ps -a --filter name=coolify-proxy --format '{{.Names}} {{.Status}}'
	     Nothing, or an Exited container, is what you want.
	  3. Applications you deploy on this box get a host port and a site block
	     in /etc/caddy/Caddyfile, the same way this dashboard did. The domain
	     field in the dashboard routes nothing while the proxy is Custom.
	  4. Your seven secrets are in $APP_DIR/source/.env, mode 640, and none was
	     printed here. APP_KEY is the one to keep: the database is encrypted
	     with it, so a dump restored without that file is unreadable.
	  5. First backup written to $BACKUP_DIR: a database dump and a config
	     archive holding source/, the host key and the Caddy site block. They
	     are on the same disk as the data, which is not a backup. Copy both
	     somewhere else tonight, and keep them together.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/vercel/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
