Can I self-host Vercel?

YES, BUT · ONE WEEKEND— setup effort 3 of 4

YES, BUT — it's called Coolify. It takes one prompt, a 2048 MB VPS, and about 300 minutes. That is $20 a month you stop paying Vercel — $240 a year on the Pro plan, 1 seat assumed.

  • vercel.com
  • Automation & dev
  • prices checked 2026-08-06

Why people pay for Vercel

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Vercel sells the distance between a git push and a working URL, and it has made that distance shorter than anyone else. You are paying for a global edge network you did not build, a preview deployment for every pull request so review happens on a real site instead of a screenshot, and a build pipeline that already knows what your framework needs. The seat price is the small half of the bill; the usage meter underneath it is the half that surprises people, and it is also the half that means you never think about capacity.

Vercel plans and list prices
PlanList priceWhat it buys
HobbyfreeFree, presented as the plan for a personal project, with monthly allowances on requests, data transfer, function invocations and active CPU.
Prothe plan this page prices against$20/mo per seat$20 per user per month, which includes $20 of usage credit; requests, data transfer and compute past the included amounts are billed on top at published unit rates.
Enterprisequote onlyQuote only. Adds SCIM, managed WAF, multi-region compute and a 99.99% uptime SLA.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: high

Replaced by Coolify

One project, named before the prompt, so you know what you are about to install.

A deploy button and a build pipeline for the server you already rent, with no seat price and no usage meter.

The closest thing to the Vercel loop that you can run yourself: connect a repository, push, and it builds the image and starts the container, with the deployment log in a browser rather than a terminal. What it does not have is the part that costs money, the global edge network and a preview URL per pull request at team scale, and what it adds is a dashboard holding a key to your server. It replaces the workflow, not the infrastructure.

The swap

You're paying

Vercel

$20/mo · $240/yr

is replaced by

You'd run

Coolify

ONE WEEKEND · ~300 min to running · 2048 MB RAM

Vercel Pro · 1 seat assumed · vendor list price · checked 2026-08-06 · source

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
30 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~300 min3–24 hours, through the first backup

The prompt

Two paths to the same Coolify: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

365 lines · 14,975 bytes

What this prompt will do
  1. Preflight
  2. Layout and host access
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

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.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

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.

346 lines · 14,997 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

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.
compose.local.ymlthe services, pinned · local layout97 lines

authored from upstream docs, never pasted · 3,513 bytes

# 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

agent-readable mirror: /self-host/vercel.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned101 lines

authored from upstream docs, never pasted · 3,744 bytes

# 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
Caddyfilethe hostname and TLS30 lines

authored from upstream docs, never pasted · 1,066 bytes

# 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.shthe same install, no agent212 lines

authored from upstream docs, never pasted · 10,255 bytes

#!/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

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Vercel.

  • This one is not sandboxed. Coolify holds a private key into the host and a passwordless sudo line for the account that key opens, because driving Docker on the machine it manages is the entire product. Anyone who reaches the dashboard reaches the server. Treat the admin password the way you would treat the root password, because on this box they amount to the same thing. Upstream marks the non-root arrangement used here experimental; its own installer enables a root login instead.
  • You own the proxy decision, and this install makes it one way. Coolify normally runs its own Traefik on ports 80 and 443 and issues a certificate per application domain. Here the server's proxy is set to Custom (None) so the Caddy from Prompt Zero keeps those ports, which means every application you deploy gets a host port and a Caddy site block you write, rather than a domain field in the dashboard.
  • It wants its own machine and you are probably not giving it one. Coolify is designed as a control plane that deploys to servers you attach to it; running it on the same box as the applications it deploys is the budget answer, and the cost is that a build can starve the thing running the build.
  • No edge network, no preview URL per pull request at team scale, and no serverless functions. You get git push, a build on your box, and one container behind one hostname. That is a smaller product than the one it replaces, and the difference is most of what the bill was buying.
  • You own the backups and the updates. The database holds every project, server and stored credential, and it is encrypted with a key that lives in a file next to it, so the dump and the config archive are one backup in two pieces. Automatic updates are switched off here on purpose, which means nobody applies a security fix but you.

Where this came from

“Coolify uses SSH to connect to your server and deploy your applications. This is true even when using the localhost server where Coolify is running.”

  • Upstream documents a manual, compose-based installation alongside its one-line installer script, which is the path this install follows: create the directory tree, generate the key, create the coolify network, then start the compose stack. source
  • The production stack is four services, the application plus PostgreSQL, Redis and a realtime server, and the application container publishes its dashboard on port 8080 inside the container with a health endpoint at /api/health. source
  • Coolify reaches the machine it deploys to over SSH even for the server it is installed on, and its documentation asks for PubkeyAuthentication yes with a key that has no passphrase. source
  • A server can be set to a Custom (None) proxy, in which case Coolify starts no proxy of its own and the operator supplies the routing; the alternatives, Traefik and Caddy, both bind ports 80 and 443 on the host. source
  • Upstream's firewall guidance opens 8000, 6001 and 6002 for a dashboard reached by IP address and states those ports can be closed once the dashboard is reached on a custom domain. source
  • Upstream documents running Coolify's host access as a non-root user with a passwordless sudo entry for that account, and marks the arrangement experimental; its default installer enables a root login instead. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Vercel?

    Not Vercel itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Coolify. A deploy button and a build pipeline for the server you already rent, with no seat price and no usage meter. The install is one weekend: 4 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 300 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Vercel?

    Coolify. A deploy button and a build pipeline for the server you already rent, with no seat price and no usage meter. The closest thing to the Vercel loop that you can run yourself: connect a repository, push, and it builds the image and starts the container, with the deployment log in a browser rather than a terminal. What it does not have is the part that costs money, the global edge network and a preview URL per pull request at team scale, and what it adds is a dashboard holding a key to your server. It replaces the workflow, not the infrastructure. Coolify is Apache-2.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Vercel?

    2048 MB of RAM and 30 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Coolify itself is free and Apache-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Vercel Pro, $20/mo — $240 a year, 1 seat assumed.

  • How hard is it really?

    ONE WEEKEND — 3–24 hours. The rule that produced that verdict: four containers. Four services still fits in a weekend, but part of that weekend is spent reading logs to work out which of the four is the one that is wrong. The tier is derived from seven countable facts about the Coolify install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Coolify on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Coolify on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Coolify reaches the machine it deploys to over a remote login channel the local path does not open on your own computer, so what you get here is the dashboard and the service catalogue to learn on, and nothing it can deploy to. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.