Can I self-host Railway?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Dokploy. It takes one prompt, a 2048 MB VPS, and about 150 minutes. That is $20 a month you stop paying Railway — $240 a year on the Pro plan.
Why people pay for Railway
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.
Railway sells the shortest distance between a git repository and a running service, and it charges for the running rather than for the seat. You connect a repo, it works out how to build it, and a database, a cron job and a worker are three clicks that arrive already networked together and already backed up. What you are paying for is that nobody on your side owns capacity: the meter runs per second on the memory and CPU you actually use, it scales down to nothing overnight, and there is an operator awake when the underlying machine is not. The subscription is the small half of the bill and the meter is the half people go looking to escape.
| Plan | List price | What it buys |
|---|---|---|
| Trial | free | $5 of credits for 30 days, no card required, capped at 2 vCPU and 1 GB per service with 7-day log history. |
| Free | free | $1 of monthly usage credits, capped at 1 vCPU and 0.5 GB per service, one replica, 3-day log history. |
| Hobby | $5/mo | $5 a month that includes $5 of usage credits, so a quiet project costs the subscription and nothing more. Up to 48 vCPU and 48 GB per service, 6 replicas, 7-day log history. |
| Prothe plan this page prices against | $20/mo | $20 a month per workspace, not per person: the page lists workspace seats as unlimited and included. It carries $20 of usage credits, and everything past them is billed at the published unit rates. |
| Enterprise | quote only | Quote only. Adds contractual SLAs, SSO, dedicated support and procurement options. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: high
Replaced by Dokploy
One project, named before the prompt, so you know what you are about to install.
Push to a git repository and get a running container, on a server you rent flat instead of a meter you watch.
The closest thing to the Railway loop you can run on a box you already pay for: connect a repository, push, and it builds the image and starts the container with the deployment log in a browser instead of a terminal. It is the lighter of the two panels here and the one whose interface feels most like the product it replaces. What it does not have is the part the meter was paying for, elastic capacity and managed databases somebody else backs up, and what it adds is a dashboard holding the Docker socket of your server, which is root with a login form in front of it.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Dokployour pickONE EVENING— setup effort 2 of 4
Push to a git repository and get a running container, on a server you rent flat instead of a meter you watch.
The closest thing to the Railway loop you can run on a box you already pay for: connect a repository, push, and it builds the image and starts the container with the deployment log in a browser instead of a terminal. It is the lighter of the two panels here and the one whose interface feels most like the product it replaces. What it does not have is the part the meter was paying for, elastic capacity and managed databases somebody else backs up, and what it adds is a dashboard holding the Docker socket of your server, which is root with a login form in front of it.
- CoolifyONE WEEKEND— setup effort 3 of 4
A deploy button and a build pipeline for the server you already rent, with no seat price and no usage meter.
Second place, and the same job done by the bigger sibling. Coolify is older, has the larger community and the deeper service catalogue, and it is one page over as the answer to Vercel. Pick it over Dokploy when you want the project more people have already broken and written up; pick Dokploy when you want the smaller footprint and the interface that reads most like Railway. Both make the same trade with your server's Docker daemon, so neither of them is the safer choice on that front.
The swap
You'd run
Dokploy
ONE EVENING · ~150 min to running · 2048 MB RAM
Railway Pro · 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
- ~150 min1–3 hours, through the first backup
The prompt
Two paths to the same Dokploy: 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.
Where it runs
339 lines · 14,984 bytes
What this prompt will do
- Preflight
- Layout and Swarm mode
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 Dokploy v0.29.14 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 here.
Say this before anything installs. Dokploy drives this host's Docker daemon through a mounted
socket and puts the machine into Swarm mode to do it. Prompt Zero called docker-group membership
root-equivalent; this hands that reach to a web dashboard, so whoever holds the panel password
holds the server.
Upstream asks for 2048 MB of RAM available and 30 GB free, 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 Swarm mode
Two trees. /srv/dokploy is ours: compose file, secrets, database directory, archives.
/etc/dokploy is the panel's, and not ours to move: it writes that path inside the container, then
hands the same string to the Docker daemon out here.
```bash
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" /srv/dokploy /srv/dokploy/backups
sudo install -d -m 700 /srv/dokploy/postgres
sudo install -d -m 750 /etc/dokploy
docker info --format '{{.Swarm.LocalNodeState}}' | grep -q active || docker swarm init --advertise-addr 127.0.0.1
docker network inspect dokploy-network >/dev/null 2>&1 || docker network create --driver overlay --attachable dokploy-network
docker info --format 'swarm={{.Swarm.LocalNodeState}}'
docker network inspect dokploy-network --format 'network={{.Name}} {{.Driver}} attachable={{.Attachable}}'
ls -la /srv/dokploy
```
Assert three: `swarm=active`, a line reading
`network=dokploy-network overlay attachable=true`, and `ls -la` showing `backups` owned by the
login user with `postgres` at mode `700` owned by root. The PostgreSQL image chowns its own data
directory on first start, so leave that alone. /etc/dokploy stays root-owned at 750 rather than
the 777 upstream's installer sets, because the container runs as root and needs no more. The
swarm advertises on 127.0.0.1 on purpose: one node, nothing will join it, and the cluster port
has no business on a public interface.
## 3. Secrets
Two secrets, both generated here: the PostgreSQL password and the key the panel signs every
session with. Print neither, and keep both out of your summary and out of any log line. Hex,
because one travels inside a connection string.
```bash
umask 077
cat > /srv/dokploy/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
AUTH_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/dokploy/.env
umask 022
ls -l /srv/dokploy/.env
```
Assert: the file exists with mode `-rw-------`. Upstream falls back to a published hard-coded
signing key when that value is unset, and warns rather than refusing to start, so this file
stands between a stranger and a forged session cookie. Tell the user it is readable with
`sudo grep AUTH_SECRET /srv/dokploy/.env` and that nothing here printed it.
## 4. compose.yml
```bash
cat > /srv/dokploy/compose.yml <<'EOF'
# Dokploy · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# installation ....... https://docs.dokploy.com/docs/core/installation
# manual install ..... https://docs.dokploy.com/docs/core/manual-installation
# architecture ....... https://docs.dokploy.com/docs/core/architecture
# applications ....... https://docs.dokploy.com/docs/core/applications
#
# Two services: the panel, and the PostgreSQL holding every project, server and
# stored credential. Upstream's installer starts a third container, its own
# Traefik, on 80 and 443; this file starts no proxy, because Caddy holds those
# on a Prompt Zero box. The panel answers on 127.0.0.1:8144, and applications
# deployed from it get a host port and a Caddy site block.
#
# /etc/dokploy is bound host path to identical container path on purpose: the
# panel hands those strings to the Docker daemon out here. The socket is
# read-write because driving this host's Docker is the product, and that is
# root on this machine. dokploy-network is the attachable overlay network the
# install step created; Swarm mode is not optional, because everything the
# panel deploys is a Swarm service. Digests read 2026-08-06; both images
# publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
dokploy:
image: dokploy/dokploy:v0.29.14@sha256:57771f6edc7921f741c004002b9be9b39cfa5cb84ecd4ba325f735219a8cae6b
container_name: dokploy
restart: unless-stopped
environment:
# Upstream reads DATABASE_URL ahead of its Docker-secret path.
DATABASE_URL: postgres://dokploy:${DB_PASSWORD}@postgres:5432/dokploy
BETTER_AUTH_SECRET: ${AUTH_SECRET}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /etc/dokploy:/etc/dokploy
- dokploy-docker:/root/.docker
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8144.
- "127.0.0.1:8144:3000"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/health || exit 1"]
interval: 10s
retries: 30
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: dokploy-postgres
restart: unless-stopped
environment:
POSTGRES_DB: dokploy
POSTGRES_USER: dokploy
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/dokploy/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dokploy -d dokploy"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
networks:
default:
name: dokploy-network
external: true
volumes:
dokploy-docker:
name: dokploy-docker
EOF
cd /srv/dokploy && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Compose reads /srv/dokploy/.env for the two `${...}` values
because it sits in the project directory. Both are plain containers on a Swarm overlay network,
which is what `--attachable` in step 2 is for.
## 5. Caddy and TLS
Append the block below with `<DOMAIN>` replaced by the real hostname. Copy first: a syntax error
here takes down every site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-dokploy
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Dokploy · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.dokploy.com/docs/core/installation and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Caddy keeps 80 and
# 443 here; upstream's installer would have given them to its own Traefik, and
# this install runs none, so every application deployed from the panel gets a
# host port and a site block of its own, written like this one.
<DOMAIN> {
# The panel carries a session cookie and an embedded shell, so it is never
# framed and leaks no path in a referrer. HSTS because whoever reaches this
# dashboard reaches the Docker daemon standing behind it.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8144 is the loopback port compose publishes on this host. It is not a
# container port and not open in the firewall. Deployment logs and the web
# terminal are WebSockets on it too, which reverse_proxy upgrades already.
reverse_proxy 127.0.0.1:8144
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: both exit 0. On failure restore /etc/caddy/Caddyfile.before-dokploy, reload, and report
what it objected to. Caddy asks for the certificate on the first request and renews it itself.
## 6. Firewall
Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:
```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 and redirects, 443/tcp is the way in, 443/udp is HTTP/3. 8144
stays closed because compose binds it to 127.0.0.1, 5432 because compose never publishes it, and
2377, 7946 and 4789 because they are for cluster members and this cluster has one. Assert:
`Status: active`, rules for 80, 443/tcp and 443/udp, none naming 8144, 5432, 2377, 7946 or
4789.
## 7. Start and verify
The panel waits for PostgreSQL, runs its own migrations, then serves. A cold first start takes
a couple of minutes.
```bash
cd /srv/dokploy
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 'Setup the server'
docker compose ps
```
Assert all five, printing what you received for each. The loop ends on `200`. `/api/health`
answers `{"ok":true}`. The root lands on `https://<DOMAIN>/register`, where an instance with no
account sends everyone. The grep prints at least `1`: that screen carries the heading
`Setup the server` above `Enter your email and password to setup the server`. `ps` shows two
containers up. On any miss, stop, run `docker compose logs --tail 40 dokploy` and
`docker compose logs --tail 20 postgres`, and name the cause: a database that never reports
healthy is step 3 with an empty `DB_PASSWORD`, a `502` is step 5, and
`network dokploy-network declared as external, but could not be found` is step 2. 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
docker ps -a --filter name=dokploy-traefik --format '{{.Names}} {{.Status}}'
```
Assert both. The first prints `https://<DOMAIN>/`: registration closes once an account exists.
The second prints nothing, because no Traefik was started, and one appearing later is a container
asking for the 80 and 443 Caddy holds.
STOP: tell the user to open Settings, then Web Server, and set the panel's domain to `<DOMAIN>`
with HTTPS on, so it knows its own public address. Do not continue until they confirm. That
setting writes a routing file and starts nothing.
## 8. First backup and restore
Two artifacts. The database holds every project, application, server and credential. The
archive holds what rebuilds the service around it, /etc/dokploy included, because the panel's
keys and routing files are there.
```bash
cd /srv/dokploy
docker compose exec -T postgres pg_dump -U dokploy -d dokploy | gzip > /srv/dokploy/backups/dokploy-db-$(date +%F).sql.gz
sudo tar -czf /srv/dokploy/backups/dokploy-config-$(date +%F).tar.gz -C /srv/dokploy compose.yml .env -C /etc dokploy -C /etc/caddy Caddyfile
ls -lh /srv/dokploy/backups/
```
Assert: both exist and are non-empty, and print both sizes. Nothing stops: `pg_dump` snapshots a
running database consistently. A backup on the same disk is not a backup, so run this from the
user's machine:
```bash
mkdir -p ~/backups/dokploy
scp vps:/srv/dokploy/backups/* ~/backups/dokploy/
```
To restore: `docker compose down`, then untar the config archive twice, once with
`-C /srv/dokploy compose.yml .env` and once with `-C /etc dokploy`, so the secrets land
first. Then `sudo rm -rf /srv/dokploy/postgres`, recreate it as in step 2,
`docker compose up -d postgres`,
wait for healthy, pipe `gunzip -c` on the dump into
`docker compose exec -T postgres psql -U dokploy -d dokploy`, then `docker compose up -d`. The
stakes, plainly: those rows hold the credentials for every repository and registry the user
connects, and the containers this panel deployed keep running while the panel is gone, which
makes a lost database feel survivable until they first need to change something.
## 9. Updating later
Versions are listed at https://github.com/Dokploy/dokploy/releases. Take both backups
first, then edit the image line in /srv/dokploy/compose.yml to the new tag and digest:
```bash
cd /srv/dokploy
docker compose pull
docker compose up -d
docker compose logs --tail 40 dokploy
```
It migrates its own database on the way up, so watch that log settle, then re-run step 7's
health check before calling the update done. Do not use the update button inside the panel: it
drives a Swarm service named `dokploy`, and this runs a compose container, so the pinned image
line above is the only thing that moves a version here.
## 10. What will probably go wrong
You will deploy something, open its Domains tab, type a hostname, and wait for a certificate
that is never coming. I did, for about fifteen minutes. Nothing is broken: that tab writes
routing rules for the Traefik this install deliberately does not run, because Caddy holds 80 and
443 here. The working shape is the other tab, Ports: give the application a published port, then
add a Caddy site block for its hostname pointing at 127.0.0.1 and that port, as in step 5. A
server where the Domains tab works is a server with no Caddy on it.
## 11. Out of scope
- Do not start a Traefik container, and do not use a panel action that offers to, including the
Traefik and Web Server controls in Settings beyond step 7's domain. Both ports it wants are
Caddy's.
- Do not run upstream's one-line installer on this box. It makes its own Swarm secrets, creates
Swarm services and starts that Traefik; this prompt has done the parts worth keeping.
- Do not configure SMTP or any notification transport, and do not connect a GitHub App or a
container registry. Each is a credential of its own and none is needed to deploy the first
application.
- Do not add a second server to the panel. That means a key and a passwordless sudo line on
another machine, a decision the user makes deliberately or not at all.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 Dokploy v0.29.14 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. Dokploy drives this server's Docker daemon through a mounted socket
and puts the machine into Swarm mode to do it. Prompt Zero called docker-group membership
root-equivalent; this hands the same reach to a web dashboard, so whoever holds the panel
password holds the server. That is the trade, and it is the product.
## 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 name that does not
resolve. The 30 GB is not padding. Application builds happen on this box, and a build that runs
out of disk fails in a way that looks like a broken panel rather than a full disk.
## 2. Layout and Swarm mode
Two trees. /srv/dokploy is yours: compose file, secrets, database directory, archives.
/etc/dokploy belongs to the panel, and that path cannot move, because the panel writes it inside
the container and then hands the same string to the Docker daemon out here.
```bash
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" /srv/dokploy /srv/dokploy/backups
sudo install -d -m 700 /srv/dokploy/postgres
sudo install -d -m 750 /etc/dokploy
docker info --format '{{.Swarm.LocalNodeState}}' | grep -q active || docker swarm init --advertise-addr 127.0.0.1
docker network inspect dokploy-network >/dev/null 2>&1 || docker network create --driver overlay --attachable dokploy-network
docker info --format 'swarm={{.Swarm.LocalNodeState}}'
docker network inspect dokploy-network --format 'network={{.Name}} {{.Driver}} attachable={{.Attachable}}'
ls -la /srv/dokploy
```
You should see: `swarm=active`, then `network=dokploy-network overlay attachable=true`, then
`backups` owned by you and `postgres` at mode `drwx------` owned by root.
If you do not: leave `postgres` owned by root on purpose, because the PostgreSQL image chowns
its own data directory on first start and one you have already chowned makes it refuse to
initialise. `Error response from daemon: This node is already part of a swarm` means the first
line found an active swarm and skipped the init, which is the intended path on a re-run. The
advertise address is 127.0.0.1 because this is a single node that nothing will ever join, and
the cluster port has no business on a public interface.
## 3. Secrets
Two secrets, both generated here on the server: the PostgreSQL password and the key the panel
signs every session with. Hex, because one travels inside a connection string.
```bash
umask 077
cat > /srv/dokploy/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
AUTH_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/dokploy/.env
umask 022
ls -l /srv/dokploy/.env
```
You should see: mode `-rw-------`, your own username twice, and the path.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/dokploy/.env` and carry
on. If the file already existed from an earlier attempt, this block has overwritten both values,
which is fine before the database exists and a problem afterwards: PostgreSQL keeps the password
it was created with, so a changed `DB_PASSWORD` against an existing directory produces an
authentication failure in the panel's log rather than anything that mentions passwords.
Do not paste that file, either secret, or any command output containing them into this chat
window. Upstream falls back to a published hard-coded signing key when `AUTH_SECRET` is unset,
warning instead of refusing to start, so this file is what makes your session cookie unforgeable,
and a chat window is a third party.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/dokploy/compose.yml <<'EOF'
# Dokploy · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# installation ....... https://docs.dokploy.com/docs/core/installation
# manual install ..... https://docs.dokploy.com/docs/core/manual-installation
# architecture ....... https://docs.dokploy.com/docs/core/architecture
# applications ....... https://docs.dokploy.com/docs/core/applications
#
# Two services: the panel, and the PostgreSQL holding every project, server and
# stored credential. Upstream's installer starts a third container, its own
# Traefik, on 80 and 443; this file starts no proxy, because Caddy holds those
# on a Prompt Zero box. The panel answers on 127.0.0.1:8144, and applications
# deployed from it get a host port and a Caddy site block.
#
# /etc/dokploy is bound host path to identical container path on purpose: the
# panel hands those strings to the Docker daemon out here. The socket is
# read-write because driving this host's Docker is the product, and that is
# root on this machine. dokploy-network is the attachable overlay network the
# install step created; Swarm mode is not optional, because everything the
# panel deploys is a Swarm service. Digests read 2026-08-06; both images
# publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
dokploy:
image: dokploy/dokploy:v0.29.14@sha256:57771f6edc7921f741c004002b9be9b39cfa5cb84ecd4ba325f735219a8cae6b
container_name: dokploy
restart: unless-stopped
environment:
# Upstream reads DATABASE_URL ahead of its Docker-secret path.
DATABASE_URL: postgres://dokploy:${DB_PASSWORD}@postgres:5432/dokploy
BETTER_AUTH_SECRET: ${AUTH_SECRET}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /etc/dokploy:/etc/dokploy
- dokploy-docker:/root/.docker
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8144.
- "127.0.0.1:8144:3000"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/health || exit 1"]
interval: 10s
retries: 30
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: dokploy-postgres
restart: unless-stopped
environment:
POSTGRES_DB: dokploy
POSTGRES_USER: dokploy
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/dokploy/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dokploy -d dokploy"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
networks:
default:
name: dokploy-network
external: true
volumes:
dokploy-docker:
name: dokploy-docker
EOF
cd /srv/dokploy && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/dokploy/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal;
run `rm /srv/dokploy/compose.yml` and paste again in one go. Note what is not in this file:
upstream's installer starts a third container, its own Traefik, on ports 80 and 443. Caddy holds
those on a Prompt Zero box, so no proxy is started here, and step 10 says what that costs you.
## 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-dokploy
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Dokploy · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.dokploy.com/docs/core/installation and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Caddy keeps 80 and
# 443 here; upstream's installer would have given them to its own Traefik, and
# this install runs none, so every application deployed from the panel gets a
# host port and a site block of its own, written like this one.
<DOMAIN> {
# The panel carries a session cookie and an embedded shell, so it is never
# framed and leaks no path in a referrer. HSTS because whoever reaches this
# dashboard reaches the Docker daemon standing behind it.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8144 is the loopback port compose publishes on this host. It is not a
# container port and not open in the firewall. Deployment logs and the web
# terminal are WebSockets on it too, which reverse_proxy upgrades already.
reverse_proxy 127.0.0.1:8144
}
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-dokploy /etc/caddy/Caddyfile`, reload,
and paste again. The panel's deployment logs and its web terminal are WebSockets on the same
8144, and `reverse_proxy` upgrades them with no extra configuration, so there is no second route
to add here.
## 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 `8144`, `5432`, `2377`, `7946` or `4789`.
If you do not: delete anything for those five with `sudo ufw delete allow 8144` and the same for
the rest. 8144 is bound to 127.0.0.1 by the compose file, 5432 is never published at all, and
2377, 7946 and 4789 are Swarm's cluster ports, which matter only when a second machine joins
this swarm. Nothing will. `Status: inactive` is a different problem: Prompt Zero left this
firewall enabled, so something has turned it off since, and `sudo ufw enable` puts it back before
you go further.
## 7. Start and verify
The panel waits for PostgreSQL, runs its own migrations, then serves. A cold first start takes
a couple of minutes, so the loop below is patient on purpose.
```bash
cd /srv/dokploy
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 'Setup the server'
docker compose ps
```
You should see, in order: the loop reaching `200`, then `{"ok":true}`, then
`https://<DOMAIN>/register`, then a number of at least `1`, then two containers `Up`.
If you do not: a loop that never reaches `200` wants
`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 dokploy` second. A `502`
from Caddy is step 5. `network dokploy-network declared as external, but could not be found` is
step 2, and re-running that block fixes it. A running container is not success: the grep is the
assert that matters, because it proves the page an unconfigured instance serves reached you
through Caddy.
The first screen at https://<DOMAIN> is the heading `Setup the server` above the line
`Enter your email and password to setup the server`. Open it in a browser and create your
account now. It is the only moment that account can be made, no mail server here can reset it,
so put the password in your password manager before you submit the form.
```bash
curl -sSL -o /dev/null -w '%{url_effective}\n' https://<DOMAIN>/register
docker ps -a --filter name=dokploy-traefik --format '{{.Names}} {{.Status}}'
```
You should see: `https://<DOMAIN>/` from the first command, and nothing at all from the second.
If you do not: a `/register` that still serves itself means the account was not created, so go
back and create it. A `dokploy-traefik` line means a proxy container exists that wants ports 80
and 443, which Caddy is holding; `docker rm -f dokploy-traefik` removes it, and step 11 says what
not to press to get it back.
Last, open Settings, then Web Server, and set the panel's domain to your hostname with HTTPS
enabled, so the panel knows its own public address. That writes a routing file and starts
nothing.
## 8. First backup and restore
Two artifacts. The database holds every project, application, server and credential. The archive
holds what rebuilds the service around it, /etc/dokploy included, because the panel's keys and
routing files are there.
```bash
cd /srv/dokploy
docker compose exec -T postgres pg_dump -U dokploy -d dokploy | gzip > /srv/dokploy/backups/dokploy-db-$(date +%F).sql.gz
sudo tar -czf /srv/dokploy/backups/dokploy-config-$(date +%F).tar.gz -C /srv/dokploy compose.yml .env -C /etc dokploy -C /etc/caddy Caddyfile
ls -lh /srv/dokploy/backups/
```
You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently.
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/dokploy
scp vps:/srv/dokploy/backups/* ~/backups/dokploy/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/dokploy/`.
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.
Now prove the restore, today, while the only thing at risk is one account:
```bash
cd /srv/dokploy
docker compose down
sudo rm -rf /srv/dokploy/postgres
sudo install -d -m 700 /srv/dokploy/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/dokploy/backups/dokploy-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U dokploy -d dokploy
docker compose up -d
sleep 30
curl -sSL -o /dev/null -w '%{url_effective}\n' https://<DOMAIN>/register
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then `https://<DOMAIN>/`, which means
your account survived a database that was deleted and rebuilt.
If you do not: `role "dokploy" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. If the last line prints
`https://<DOMAIN>/register` instead, the restore did not land and your account is gone; take the
dump apart before you trust this install with anything. Understand the stakes: those rows hold
the credentials for every repository and registry you connect, and the containers this panel
deployed keep running while the panel is gone, which makes a lost database feel survivable right
up to the first time you need to change something.
## 9. Updating later
Versions are listed at https://github.com/Dokploy/dokploy/releases. Take both backup artifacts
first, then edit the `image:` line in /srv/dokploy/compose.yml to the new tag and its digest.
```bash
cd /srv/dokploy
docker compose pull
docker compose up -d
docker compose logs --tail 40 dokploy
```
You should see: migration output, then the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Then re-run
step 7's health check before you call the update done. Do not use the update button inside the
panel: it drives a Swarm service named `dokploy`, and this install runs a compose container, so
the pinned image line is the only thing that moves a version here.
## 10. What will probably go wrong
You will deploy something, open its Domains tab, type a hostname, and wait for a certificate
that is never coming. I did, for about fifteen minutes. Nothing is broken: that tab writes
routing rules for the Traefik this install deliberately does not run, because Caddy holds 80 and
443 here. The working shape is the other tab, Ports: give the application a published port, then
add a Caddy site block for its hostname pointing at 127.0.0.1 and that port, as in step 5. A
server where the Domains tab works is a server with no Caddy on it.
## 11. Out of scope
- Do not start a Traefik container, and do not use a panel action that offers to, including the
Traefik and Web Server controls in Settings beyond step 7's domain. Both ports it wants are
Caddy's.
- Do not run upstream's one-line installer on this box. It makes its own Swarm secrets, creates
Swarm services and starts that Traefik; this guide has done the parts worth keeping.
- Do not configure SMTP or any notification transport, and do not connect a GitHub App or a
container registry. Each is a credential of its own and none is needed to deploy the first
application.
- Do not add a second server to the panel. That means a key and a passwordless sudo line on
another machine, a decision you make deliberately or not at all.323 lines · 14,991 bytes
What this prompt will do
- Preflight
- Docker
- Layout and Swarm mode
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 Dokploy v0.29.14 and the PostgreSQL it keeps every project in, under ~/selfhost/dokploy,
answering at http://localhost:8144.
## 1. Preflight
Say this before step 2, because it decides whether they want this install at all. Dokploy is a
deploy panel, and here it deploys to this computer: what it builds runs on this machine's
Docker, at localhost and nowhere else, and stops when the machine sleeps. And it drives that
Docker daemon through a mounted socket, in Swarm mode, which is an administrator account handed
to a web page.
Detect the OS and measure it:
```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. Upstream asks for 2048 MB of RAM available
and 30 GB free, a floor rather than a budget, because builds run here too. Both images publish
amd64 and arm64; on macOS and Windows that memory is the host's, and Docker Desktop takes a
share. If RAM available 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 and Swarm mode
Everything the panel deploys is a Swarm service, so Swarm mode is not optional here, and the
network has to exist first.
```bash
mkdir -p ~/selfhost/dokploy/config ~/selfhost/dokploy/backups
docker info --format '{{.Swarm.LocalNodeState}}' | grep -q active || docker swarm init --advertise-addr 127.0.0.1
docker network inspect dokploy-network >/dev/null 2>&1 || docker network create --driver overlay --attachable dokploy-network
docker info --format 'swarm={{.Swarm.LocalNodeState}}'
docker network inspect dokploy-network --format 'network={{.Name}} {{.Driver}} attachable={{.Attachable}}'
ls -la ~/selfhost/dokploy
```
Assert three: `swarm=active`, a line reading
`network=dokploy-network overlay attachable=true`, and `ls -la` showing `config` and `backups`
owned by the user. `config` is the panel's tree, seen at /etc/dokploy inside the container; on
Linux it writes there as root, and on macOS and Windows Docker Desktop settles ownership. No
database folder: step 5 keeps PostgreSQL in a volume.
## 4. Secrets
Two secrets, both generated here: the PostgreSQL password and the key the panel signs sessions
with. Print neither, and keep both out of your summary and out of any log line.
```bash
umask 077
cat > ~/selfhost/dokploy/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
AUTH_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/dokploy/.env
umask 022
ls -l ~/selfhost/dokploy/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these run the same on
all three systems. Upstream falls back to a published hard-coded signing key when that value is
unset, warning rather than refusing to start, so this file is what makes the session cookie here
unforgeable. On Windows those mode bits are advisory: NTFS does not enforce them, and the real
boundary is the account.
## 5. compose.yml
```bash
cat > ~/selfhost/dokploy/compose.yml <<'EOF'
# Dokploy · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# installation ....... https://docs.dokploy.com/docs/core/installation
# manual install ..... https://docs.dokploy.com/docs/core/manual-installation
#
# Two services, every path relative to ~/selfhost/dokploy/ so one file works on
# macOS, Linux and Windows. PostgreSQL uses a named volume because it chowns
# that directory to a uid of its own, which a Windows bind mount cannot allow.
# No proxy container: nothing is public and the panel answers on 127.0.0.1:8144.
# Its own tree is /etc/dokploy in the container and ./config out here, so a file
# the panel mounts points at a path this machine has not got. dokploy-network is
# the attachable overlay network the install step made, and Swarm mode is not
# optional. Digests read 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
dokploy:
image: dokploy/dokploy:v0.29.14@sha256:57771f6edc7921f741c004002b9be9b39cfa5cb84ecd4ba325f735219a8cae6b
container_name: dokploy
restart: unless-stopped
environment:
# Upstream reads DATABASE_URL ahead of its Docker-secret path.
DATABASE_URL: postgres://dokploy:${DB_PASSWORD}@postgres:5432/dokploy
BETTER_AUTH_SECRET: ${AUTH_SECRET}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/etc/dokploy
- dokploy-docker:/root/.docker
ports:
# Loopback only: no other device on the wifi can reach 8144.
- "127.0.0.1:8144:3000"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/health || exit 1"]
interval: 10s
retries: 30
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: dokploy-postgres
restart: unless-stopped
environment:
POSTGRES_DB: dokploy
POSTGRES_USER: dokploy
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- dokploy-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dokploy -d dokploy"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
networks:
default:
name: dokploy-network
external: true
volumes:
dokploy-docker:
name: dokploy-docker
dokploy-pgdata:
name: dokploy-pgdata
EOF
cd ~/selfhost/dokploy && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`. Two services, one published port, two volumes.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
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: 8144 binds to 127.0.0.1, this computer only, not the user's phone and
not a laptop on the wifi. For a panel holding the keys to this machine that is the point of this
path, not a shortcoming. Confirm it:
```bash
grep -c '"127.0.0.1:' ~/selfhost/dokploy/compose.yml
```
Assert: `1`, the panel's port. PostgreSQL publishes none, so 5432 cannot appear, and anything
deployed later is the user's to keep on loopback too.
## 7. Start and verify
The panel waits for PostgreSQL, migrates, then serves. A cold start takes minutes.
```bash
cd ~/selfhost/dokploy
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:8144/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8144/api/health; echo
curl -sSL -o /dev/null -w '%{url_effective}\n' http://localhost:8144/
curl -sSL http://localhost:8144/register | grep -c 'Setup the server'
docker compose ps
```
Assert all five, printing what you got for each. The loop ends on `200`. `/api/health` answers
`{"ok":true}`. The root lands on `http://localhost:8144/register`, where an instance with no
account sends everyone. The grep prints at least `1`: that screen carries the heading
`Setup the server` above `Enter your email and password to setup the server`, and `ps` shows
both containers up. On any miss, stop, run `docker compose logs --tail 40 dokploy` and name the
cause: a database that never reports healthy is step 4 with a blank `DB_PASSWORD`;
`network dokploy-network declared as external, but could not be found` is step 3; and
`port is already allocated` means something else holds 8144, which
`lsof -nP -iTCP:8144 -sTCP:LISTEN` names. A running container is not success.
STOP: tell the user to open http://localhost:8144 and create their account there. It is the only
moment it can be made and nothing 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' http://localhost:8144/register
```
Assert: `http://localhost:8144/`. Registration closes once an account exists.
## 8. First backup and restore
Two artifacts: the database with every project, application and credential, and an archive of
the files and panel tree that rebuild the service around it.
```bash
cd ~/selfhost/dokploy
docker compose exec -T postgres pg_dump -U dokploy -d dokploy | gzip > ~/selfhost/dokploy/backups/dokploy-db-$(date +%F).sql.gz
tar -C ~/selfhost/dokploy -czf ~/selfhost/dokploy/backups/dokploy-config-$(date +%F).tar.gz compose.yml .env config
ls -lh ~/selfhost/dokploy/backups/
```
Assert: both exist and are non-empty, and print both sizes. Nothing stops: `pg_dump` snapshots
a running database.
Both 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 off 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. If they have neither, say plainly
that this has no backup.
To restore, in this order. `cd ~/selfhost/dokploy` and untar the archive there first, so
compose.yml, .env and config land before any container starts: PostgreSQL takes `DB_PASSWORD`
from .env the moment it initialises an empty volume, and a missing .env means a blank password
and a database that will not start. Then `docker compose down -v`, the one place `-v` belongs
because it drops the old volume on purpose, `docker compose up -d postgres`, wait 30 seconds,
pipe `gunzip -c` on the dump into
`docker compose exec -T postgres psql -U dokploy -d dokploy`, then `docker compose up -d` and
log in once. That is the disaster plan.
## 9. Updating later
Versions are at https://github.com/Dokploy/dokploy/releases. Back up first, then edit the image
line in ~/selfhost/dokploy/compose.yml to the new tag and digest:
```bash
cd ~/selfhost/dokploy
docker compose pull
docker compose up -d
docker compose logs --tail 40 dokploy
```
It migrates its own database on the way up, so watch that log settle, then re-run step 7's
health check. Do not use the panel's update button: it drives a Swarm service named `dokploy`,
and this runs a compose container, so the pinned image line is the only thing that moves a
version here.
## 10. What will probably go wrong
I rebooted this machine, opened http://localhost:8144, 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 8144, and `restart: unless-stopped` only acts once the daemon is up. The other half
of it is that everything it had deployed was down too, on that same daemon. Turn on Docker
Desktop's start-at-login, and after a reboot run
`cd ~/selfhost/dokploy && docker compose up -d` before concluding anything broke.
## 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 start a Traefik container, and do not use a panel action that offers to. It wants 80
and 443 here, and this path publishes nothing outside loopback.
- Do not rebind 8144 to 0.0.0.0 so a phone can reach the panel. That puts a dashboard with
administrator reach over this machine on every network the user joins.
- Do not add a second server, and do not configure SMTP, a notification transport or a GitHub
App. Each is a credential of its own and none is needed here.compose.local.ymlthe services, pinned · local layout66 lines
# Dokploy · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# installation ....... https://docs.dokploy.com/docs/core/installation
# manual install ..... https://docs.dokploy.com/docs/core/manual-installation
#
# Two services, every path relative to ~/selfhost/dokploy/ so one file works on
# macOS, Linux and Windows. PostgreSQL uses a named volume because it chowns
# that directory to a uid of its own, which a Windows bind mount cannot allow.
# No proxy container: nothing is public and the panel answers on 127.0.0.1:8144.
# Its own tree is /etc/dokploy in the container and ./config out here, so a file
# the panel mounts points at a path this machine has not got. dokploy-network is
# the attachable overlay network the install step made, and Swarm mode is not
# optional. Digests read 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
dokploy:
image: dokploy/dokploy:v0.29.14@sha256:57771f6edc7921f741c004002b9be9b39cfa5cb84ecd4ba325f735219a8cae6b
container_name: dokploy
restart: unless-stopped
environment:
# Upstream reads DATABASE_URL ahead of its Docker-secret path.
DATABASE_URL: postgres://dokploy:${DB_PASSWORD}@postgres:5432/dokploy
BETTER_AUTH_SECRET: ${AUTH_SECRET}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/etc/dokploy
- dokploy-docker:/root/.docker
ports:
# Loopback only: no other device on the wifi can reach 8144.
- "127.0.0.1:8144:3000"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/health || exit 1"]
interval: 10s
retries: 30
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: dokploy-postgres
restart: unless-stopped
environment:
POSTGRES_DB: dokploy
POSTGRES_USER: dokploy
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- dokploy-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dokploy -d dokploy"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
networks:
default:
name: dokploy-network
external: true
volumes:
dokploy-docker:
name: dokploy-docker
dokploy-pgdata:
name: dokploy-pgdataagent-readable mirror: /self-host/railway.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, pinned71 lines
# Dokploy · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# installation ....... https://docs.dokploy.com/docs/core/installation
# manual install ..... https://docs.dokploy.com/docs/core/manual-installation
# architecture ....... https://docs.dokploy.com/docs/core/architecture
# applications ....... https://docs.dokploy.com/docs/core/applications
#
# Two services: the panel, and the PostgreSQL holding every project, server and
# stored credential. Upstream's installer starts a third container, its own
# Traefik, on 80 and 443; this file starts no proxy, because Caddy holds those
# on a Prompt Zero box. The panel answers on 127.0.0.1:8144, and applications
# deployed from it get a host port and a Caddy site block.
#
# /etc/dokploy is bound host path to identical container path on purpose: the
# panel hands those strings to the Docker daemon out here. The socket is
# read-write because driving this host's Docker is the product, and that is
# root on this machine. dokploy-network is the attachable overlay network the
# install step created; Swarm mode is not optional, because everything the
# panel deploys is a Swarm service. Digests read 2026-08-06; both images
# publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
dokploy:
image: dokploy/dokploy:v0.29.14@sha256:57771f6edc7921f741c004002b9be9b39cfa5cb84ecd4ba325f735219a8cae6b
container_name: dokploy
restart: unless-stopped
environment:
# Upstream reads DATABASE_URL ahead of its Docker-secret path.
DATABASE_URL: postgres://dokploy:${DB_PASSWORD}@postgres:5432/dokploy
BETTER_AUTH_SECRET: ${AUTH_SECRET}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /etc/dokploy:/etc/dokploy
- dokploy-docker:/root/.docker
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8144.
- "127.0.0.1:8144:3000"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/health || exit 1"]
interval: 10s
retries: 30
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: dokploy-postgres
restart: unless-stopped
environment:
POSTGRES_DB: dokploy
POSTGRES_USER: dokploy
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/dokploy/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dokploy -d dokploy"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
networks:
default:
name: dokploy-network
external: true
volumes:
dokploy-docker:
name: dokploy-dockerCaddyfilethe hostname and TLS31 lines
# Dokploy · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.dokploy.com/docs/core/installation and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Caddy keeps 80 and
# 443 here; upstream's installer would have given them to its own Traefik, and
# this install runs none, so every application deployed from the panel gets a
# host port and a site block of its own, written like this one.
<DOMAIN> {
# The panel carries a session cookie and an embedded shell, so it is never
# framed and leaks no path in a referrer. HSTS because whoever reaches this
# dashboard reaches the Docker daemon standing behind it.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8144 is the loopback port compose publishes on this host. It is not a
# container port and not open in the firewall. Deployment logs and the web
# terminal are WebSockets on it too, which reverse_proxy upgrades already.
reverse_proxy 127.0.0.1:8144
}install.shthe same install, no agent170 lines
#!/usr/bin/env bash
# Dokploy · 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://docs.dokploy.com/docs/core/installation
# https://docs.dokploy.com/docs/core/manual-installation
# https://docs.dokploy.com/docs/core/architecture
# https://docs.dokploy.com/docs/core/applications
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# the key the panel signs every session with. Both go into /srv/dokploy/.env
# with mode 600 and neither is ever printed.
#
# Read this before running it. Dokploy drives this host's Docker daemon through
# a mounted socket and puts the machine into Swarm mode to do it, so whoever
# holds the panel password holds this server. This script starts no Traefik:
# Caddy keeps 80 and 443, and applications you deploy later get a host port and
# a Caddy site block of their own.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/dokploy}"
PANEL_DIR="${PANEL_DIR:-/etc/dokploy}"
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"
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"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; the panel plus PostgreSQL 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 because builds run here"
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, and put Docker into Swarm mode --------------------
#
# /srv/dokploy is ours. /etc/dokploy is the panel's, and the path cannot move:
# the panel writes it inside the container and hands the same string to the
# Docker daemon out here. Mode 750 root-owned rather than upstream's 777,
# because the container runs as root and needs nothing wider.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
sudo install -d -m 750 "$PANEL_DIR"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# One node, nothing will ever join it, so the cluster port advertises on
# loopback rather than a public interface.
docker info --format '{{.Swarm.LocalNodeState}}' | grep -q active \
|| docker swarm init --advertise-addr 127.0.0.1
docker network inspect dokploy-network >/dev/null 2>&1 \
|| docker network create --driver overlay --attachable dokploy-network
docker info --format 'swarm={{.Swarm.LocalNodeState}}' | grep -q 'swarm=active' \
|| die "Docker is not in Swarm mode, and everything this panel deploys is a Swarm service"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex for both: one travels inside a PostgreSQL connection string. Read them
# later with
# sudo grep -E 'DB_PASSWORD|AUTH_SECRET' /srv/dokploy/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
DB_PASSWORD=$(openssl rand -hex 32)
AUTH_SECRET=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-dokploy"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/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 other five --------------------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8144, 5432 and the Swarm ports 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 panel waits for PostgreSQL, runs its own migrations, then serves.
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 dokploy"
curl -sS "https://${DOMAIN_HOST}/api/health" | grep -q '"ok":true' \
|| die "/api/health answered 200 without ok:true. Check: docker compose logs --tail 40 dokploy"
# An instance with no account sends everyone to /register, and that page is the
# proof the panel reached you through Caddy rather than a proxy error.
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:-nothing}, not /register. Stop and investigate."
curl -sSL "https://${DOMAIN_HOST}/register" | grep -q 'Setup the server' \
|| die "the first screen did not carry 'Setup the server'. Check: docker compose logs --tail 40 dokploy"
# No proxy of the panel's own should exist. One that does wants 80 and 443.
[ -z "$(docker ps -a --filter name=dokploy-traefik --format '{{.Names}}')" ] \
|| die "a dokploy-traefik container exists and wants ports 80 and 443, which Caddy holds. Remove it."
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U dokploy -d dokploy | gzip > "$APP_DIR/backups/dokploy-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/dokploy-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc dokploy -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/dokploy-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
Dokploy is answering at https://${DOMAIN_HOST}/api/health
1. Open https://${DOMAIN_HOST} and create your account. The first screen
reads "Setup the server". That account is the only one this instance
will ever offer to make, and no mail server here can reset it, so put
the password in your password manager first.
2. Then open Settings, Web Server, and set the panel's domain to
${DOMAIN_HOST} with HTTPS enabled, so the panel knows its own address.
3. No Traefik was started. Caddy keeps 80 and 443, so the Domains tab on
an application will not issue anything. Give the application a
published port instead, then add a Caddy site block for its hostname
pointing at 127.0.0.1 and that port, the way $APP_DIR/Caddyfile does.
4. Your two secrets are in $APP_DIR/.env, mode 600, and were not printed:
sudo grep -E 'DB_PASSWORD|AUTH_SECRET' $APP_DIR/.env
5. First backup written to $APP_DIR/backups: a database dump and a config
archive that includes /etc/dokploy. They are on the same disk as the
data, which is not a backup. Copy them somewhere else tonight.
DONEWhat 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 Railway.
- This is not sandboxed, and the sandbox is not coming. The panel mounts this server's Docker socket and switches the machine into Swarm mode so it can create services on it, which is root on that box by another name. Anyone who reaches the dashboard reaches the server, so treat the panel password the way you treat the root password, because here they amount to the same thing.
- You own the proxy decision, and this install makes it one way. Upstream runs its own Traefik on ports 80 and 443 and issues a certificate per application domain. Those ports belong to the Caddy from Prompt Zero, so no Traefik is started, the Domains tab on an application will not issue anything, and each application you deploy gets a published port plus a Caddy site block you write. That is a real subtraction from the product, and it is the honest one on a box that already has a reverse proxy.
- Apache-2.0, with a carve-out worth reading. The licence file reserves anything under a /proprietary directory for Dokploy's own source-available licence, and that directory exists inside the source tree, so parts of what you are running are not covered by the Apache grant. GitHub reports the repository as having no single licence for exactly that reason.
- You own the backups and the updates, and the database is the whole install. It holds every project, application, server and the credentials for every repository and registry you connect. The containers it deployed keep running while the panel is gone, which makes a lost database feel survivable right up to the moment you need to change something. Nobody applies a security fix here but you, and the panel's own update button is switched off by this layout.
- No managed databases with somebody else's backup schedule, no per-second scaling, no global network, and no support contract. You get a build on your own box, one container behind one hostname, and a bill that does not move when your traffic does. The difference between those two is most of what the metered plan was buying.
Where this came from
“Before installing Dokploy, ensure the following ports are available on your server: Port 80: HTTP traffic (used by Traefik), Port 443: HTTPS traffic (used by Traefik), Port 3000: Dokploy web interface”
- Upstream's manual installation puts the panel in Docker Swarm on an attachable overlay network, binds /var/run/docker.sock and /etc/dokploy into the container, and publishes the interface on port 3000, which is the topology this install reproduces without upstream's Traefik container. source
- Upstream asks for at least 2 GB of RAM and 30 GB of disk, and for ports 80, 443 and 3000 to be free, the first two because its own Traefik wants them. source
- The panel reads DATABASE_URL ahead of its Docker-secret path, and falls back to a published hard-coded database password when neither is set, which is why this install supplies the connection string itself. source
- BETTER_AUTH_SECRET is read from the environment, and an instance that leaves it unset falls back to a hard-coded session key that upstream's own warning calls a critical security risk. source
- The self-hosted deployment queue runs in memory rather than on Redis, so the stack behind this panel is two services and not three. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Railway?
Not Railway 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 Dokploy. Push to a git repository and get a running container, on a server you rent flat instead of a meter you watch. The install is one evening: 2 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 150 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 Railway?
Dokploy. Push to a git repository and get a running container, on a server you rent flat instead of a meter you watch. The closest thing to the Railway loop you can run on a box you already pay for: connect a repository, push, and it builds the image and starts the container with the deployment log in a browser instead of a terminal. It is the lighter of the two panels here and the one whose interface feels most like the product it replaces. What it does not have is the part the meter was paying for, elastic capacity and managed databases somebody else backs up, and what it adds is a dashboard holding the Docker socket of your server, which is root with a login form in front of it. Also evaluated and ranked below it: Coolify. The prompt on this page installs Dokploy only. Dokploy 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 Railway?
2048 MB of RAM and 30 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Dokploy itself is free and Apache-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Railway Pro, $20/mo — $240 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Dokploy install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Dokploy 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 Dokploy 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: Everything this panel deploys runs on the computer you installed it on and answers at localhost only, so what you build here is reachable from that one desk, and it stops when the machine sleeps. 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-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.