Can I self-host Pipedrive?

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

YES, BUT — it's called EspoCRM. It takes one prompt, a 2048 MB VPS, and about 240 minutes. That is $147 a month you stop paying Pipedrive — $1,764 a year on the Growth plan, 3 seats assumed.

Why people pay for Pipedrive

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.

Pipedrive sells a pipeline you can see. The deal board, the activity reminders and the two-way mail sync are arranged so that a salesperson knows what to do next without first being taught a data model, and that clarity is genuinely hard to build. What you rent alongside it is somebody else's uptime and somebody else's backups. The bill climbs on the axis a growing team cannot avoid: it is per seat, every month, and the things a sales manager eventually wants (lead routing, scoring, e-signatures, account security rules) sit on the upper two rungs of a four-rung ladder, with five metered add-ons priced beside it.

Pipedrive plans and list prices
PlanList priceWhat it buys
Lite$24/mo per seatBilled monthly. $14 per seat per month billed annually, charged as one payment of $168 per seat per year.
Growththe plan this page prices against$49/mo per seatBilled monthly. $39 per seat per month billed annually, one payment of $468 per seat per year. This is the first tier with full email sync, automations and sequences.
Premium$79/mo per seatBilled monthly. $59 per seat per month billed annually, one payment of $708 per seat per year. Marked most popular on the page, and the first tier with lead generation and routing, scoring, and e-signatures.
Ultimate$99/mo per seatBilled monthly. $79 per seat per month billed annually, one payment of $948 per seat per year. Adds account security rules, contact data enrichment and a sandbox.

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

Replaced by EspoCRM

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

A whole sales CRM on your own hostname: pipelines, contacts, activities and a REST API, with nobody counting seats.

The closest thing to a whole Pipedrive you can run yourself: pipelines and deal stages, contacts and organisations, activities with reminders, quotes and documents, a documented REST API with an OpenAPI spec, and no seat counter anywhere. AGPL-3.0, released continuously, with an official Docker image its own vendor maintains. What you take on is real: four containers, a MariaDB you back up, and a background daemon that has to keep running or the CRM keeps answering while silently doing nothing on a schedule.

The swap

You're paying

Pipedrive

$147/mo · $1,764/yr

is replaced by

You'd run

EspoCRM

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

Pipedrive Growth · 3 seats 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
10 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
~240 min3–24 hours, through the first backup

The prompt

Two paths to the same EspoCRM: 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

345 lines · 14,889 bytes

What this prompt will do
  1. Preflight
  2. Layout
  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 EspoCRM 10.0.3 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.
EspoCRM writes it into its own config on first start and builds every link it mails out of it,
so it is settled now. Its A record must point at this server already.

EspoCRM and MariaDB need 2048 MB of RAM available and 10 GB free on /srv. Both images publish
amd64 and arm64.

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

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

## 2. Layout

Root owns most of this on purpose: the EspoCRM containers chown their three directories to
www-data on the way up, MariaDB chowns its own to the uid it runs as, and a directory pre-owned
by the login user is how each of them fails a first boot.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/espocrm /srv/espocrm/backups
sudo install -d -m 700 /srv/espocrm/db
sudo install -d -m 755 /srv/espocrm/data /srv/espocrm/custom /srv/espocrm/client-custom
ls -la /srv/espocrm
```

Assert: `backups` is owned by the login user, `db` is `drwx------` owned by root, the other three
are `drwxr-xr-x` owned by root. `data` is the one that matters after today: the config file, the
logs and every attachment anybody uploads live in it.

## 3. Secrets

Three: the password EspoCRM connects to its database with, MariaDB's root password, and the
administrator password EspoCRM sets on first start. Generate all three on the server. Do not
print them, do not repeat them in your summary, do not put them in a log line. Hex for the two
inside a connection string, base64 for the one a human types.

```bash
umask 077
cat > /srv/espocrm/.env <<EOF
ESPOCRM_SITE_URL=https://<DOMAIN>
ESPOCRM_WEB_SOCKET_URL=wss://<DOMAIN>/ws
ESPOCRM_DATABASE_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ESPOCRM_ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/espocrm/.env
umask 022
ls -l /srv/espocrm/.env
```

Assert: mode `-rw-------`. Left unset, upstream's entrypoint falls back to a built-in default for
the admin and the database account, warns in the log, and starts anyway, so this block closes
that door before the first request arrives and step 7 proves it closed. Tell the user the
administrator password is applied once, on first start: editing this file later changes nothing,
and the change has to be made inside the CRM.

## 4. compose.yml

```bash
cat > /srv/espocrm/compose.yml <<'EOF'
# EspoCRM · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.espocrm.com/administration/docker/installation/
#   caddy topology ..... https://docs.espocrm.com/administration/docker/caddy/
#   entrypoint script .. https://github.com/espocrm/espocrm-docker/blob/master/docker-entrypoint.sh
#   jobs and the daemon  https://docs.espocrm.com/administration/jobs/
#
# Four services. Only espocrm answers a browser: it is Apache plus PHP. The
# daemon runs the job queue, where notification email, mass mailing, inbound
# mail checking and cleanup happen, so a CRM without it looks healthy and does
# nothing on schedule. The websocket carries live updates, MariaDB the rows.
# Digests read from Docker Hub on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: espocrm

# The three EspoCRM services run one image over one set of directories, which
# upstream does with volumes_from. Compose ignores x- keys, so the pin below
# covers all three.
x-espocrm: &espocrm
  image: espocrm/espocrm:10.0.3@sha256:a2664ea087c2cbe2dc4bf3306c56b985402c19c2e758b39463742fae14dca513
  restart: unless-stopped
  volumes:
    - /srv/espocrm/data:/var/www/html/data
    - /srv/espocrm/custom:/var/www/html/custom
    - /srv/espocrm/client-custom:/var/www/html/client/custom

services:
  espocrm-db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: espocrm
      MARIADB_USER: espocrm
      MARIADB_PASSWORD: ${ESPOCRM_DATABASE_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    volumes:
      - /srv/espocrm/db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 20s
      start_period: 30s
      timeout: 10s
      retries: 6
    # No `ports:`: 3306 is reachable only from the other containers.

  espocrm:
    <<: *espocrm
    environment:
      ESPOCRM_DATABASE_HOST: espocrm-db
      ESPOCRM_DATABASE_USER: espocrm
      ESPOCRM_DATABASE_PASSWORD: ${ESPOCRM_DATABASE_PASSWORD}
      ESPOCRM_ADMIN_USERNAME: admin
      ESPOCRM_ADMIN_PASSWORD: ${ESPOCRM_ADMIN_PASSWORD}
      ESPOCRM_SITE_URL: ${ESPOCRM_SITE_URL}
    healthcheck:
      # First start installs and builds the schema: minutes, not seconds.
      test: ["CMD", "bin/command", "app-check"]
      interval: 30s
      start_period: 180s
      timeout: 20s
      retries: 5
    ports:
      # Loopback only: the host's Caddy is the only thing reaching 8128.
      - "127.0.0.1:8128:80"
    depends_on:
      espocrm-db:
        condition: service_healthy

  espocrm-daemon:
    <<: *espocrm
    entrypoint: docker-daemon.sh
    depends_on:
      espocrm:
        condition: service_healthy
    # No healthcheck, no `ports:`: app-check reads the shared config, not the
    # job loop, so container state is the honest signal here.

  espocrm-websocket:
    <<: *espocrm
    entrypoint: docker-websocket.sh
    environment:
      # Written into the config all three share, which is how the app container
      # learns where to publish notifications.
      ESPOCRM_CONFIG_USE_WEB_SOCKET: "true"
      ESPOCRM_CONFIG_WEB_SOCKET_URL: ${ESPOCRM_WEB_SOCKET_URL}
      ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN: "tcp://*:7777"
      ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN: "tcp://espocrm-websocket:7777"
    ports:
      # Loopback only: Caddy sends the /ws route here and nothing else can.
      - "127.0.0.1:8228:8080"
    depends_on:
      espocrm:
        condition: service_healthy
EOF
cd /srv/espocrm && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Every `${...}` comes from step 3's `.env`, which
`docker compose` reads from this directory on its own, so no secret is in this file. Two
published ports, both loopback, and a database publishing nothing.

## 5. Caddy and TLS

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

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-espocrm
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# EspoCRM · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.espocrm.com/administration/docker/caddy/ 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. That hostname is
# also ESPOCRM_SITE_URL in .env, which EspoCRM writes into its own config on
# first start and builds every link it mails out of.

<DOMAIN> {
	# A JavaScript bundle and a JSON API, both worth compressing.
	encode zstd gzip

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

	# Live notifications ride a WebSocket on a container of its own, at the
	# exact path EspoCRM is told to dial. reverse_proxy upgrades the connection
	# and forwards Host, X-Forwarded-For and X-Forwarded-Proto already.
	reverse_proxy /ws 127.0.0.1:8228

	# 8128 is the loopback port compose publishes for Apache. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8128
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-espocrm, reload, and report what it objected to. Caddy takes the
certificate on the first request and renews it alone. One site, two routes: `/ws` reaches the
websocket container, everything else Apache.

## 6. Firewall

Three ports open, all Caddy's, and 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 to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8128 and 8228 stay closed because compose binds both to 127.0.0.1 and only Caddy needs
them, and 3306 stays closed because the database publishes no host port at all. Assert:
`Status: active`, rules for 80, 443/tcp and 443/udp, nothing for 8128, 8228 or 3306.

## 7. Start and verify

The first start is slow and that is correct: the app container runs the installer and builds the
schema while the other two wait on its health check.

```bash
cd /srv/espocrm
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>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/ | grep -o '<title>EspoCRM</title>'
curl -sS -o /dev/null -w '%{http_code}\n' -u admin:password https://<DOMAIN>/api/v1/App/user
docker compose exec -T espocrm bin/command config:get useWebSocket
docker compose ps
```

Assert all five and print what you received for each. The loop ends on `200`. The second prints
`<title>EspoCRM</title>`. The third prints `401`, the security assert here: `admin` with
upstream's built-in default is the first login a scanner tries, and `401` proves step 3 replaced
it. The fourth prints `true`, which happens only once the websocket container has written to the
config all three share. The fifth lists four services, `espocrm-db` and `espocrm` healthy. If any
miss, stop, run `docker compose logs --tail 40 espocrm` and
`docker compose logs --tail 20 espocrm-db`, and name the likely step: `502` means nothing is
listening on 8128 yet, a database connection error points at step 3, a daemon restarting in a
loop means the app never reported healthy. A running container is not success.

The first screen at https://<DOMAIN> is a login panel with a `Username` field, a `Password` field
and a `Log in` button.

STOP: tell the user to read their password with
`sudo grep ESPOCRM_ADMIN_PASSWORD /srv/espocrm/.env`, put it in their password manager, sign in
at https://<DOMAIN> as `admin`, and confirm the CRM loads. Wait. Do not continue until they
confirm. It is the only account here.

## 8. First backup and restore

Two artifacts. Every contact, deal and note is a row in MariaDB; the archive holds what rebuilds
the service around those rows, attachments and config file included.

```bash
cd /srv/espocrm
docker compose exec -T espocrm-db sh -c 'exec mariadb-dump -u espocrm -p"$MARIADB_PASSWORD" --single-transaction espocrm' | gzip > /srv/espocrm/backups/espocrm-db-$(date +%F).sql.gz
sudo tar -czf /srv/espocrm/backups/espocrm-files-$(date +%F).tar.gz -C /srv/espocrm compose.yml .env data custom client-custom -C /etc/caddy Caddyfile
ls -lh /srv/espocrm/backups/
```

Assert: both exist and both are non-empty. Print both sizes. Nothing goes offline, because
`--single-transaction` snapshots a running InnoDB database consistently, and the password reaches
`mariadb-dump` from the container's own environment, so it is in neither the host process list
nor the shell history.

A backup on the same disk is not a backup, so run this from the user's machine:

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

To restore: `docker compose down`, `sudo rm -rf /srv/espocrm/db`, recreate it as in step 2,
`sudo tar -xzf <archive> -C /srv/espocrm compose.yml .env data custom client-custom`,
`docker compose up -d espocrm-db`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T espocrm-db sh -c 'exec mariadb -u espocrm -p"$MARIADB_PASSWORD" espocrm'`,
then `docker compose up -d`. Order matters: the archive carries `data/config-internal.php`, and
rows restored without it are rows nothing knows how to open.

## 9. Updating later

New versions are listed at https://github.com/espocrm/espocrm/releases. Take both backups first,
then edit the `espocrm/espocrm` image line in /srv/espocrm/compose.yml to the new tag and digest.
It is one line for three services, which is the point of the `x-espocrm` block.

```bash
cd /srv/espocrm
docker compose pull
docker compose up -d
docker compose logs --tail 30 espocrm
```

EspoCRM migrates its own database on the way up and refuses to start if a customization is
incompatible with the new release, naming the version to go back to. Watch that log settle, then
re-run step 7's five checks.

## 10. What will probably go wrong

You will think it failed while it was still working. `docker compose up -d` returns in seconds,
https://<DOMAIN> answers `502` for two or three minutes while the app container installs itself
and builds the schema, and `docker compose ps` shows the daemon and websocket created rather than
running, because both wait on a health check that has not passed. I read that as a broken install
and had my hand on `docker compose down` before the login page appeared. Give step 7's loop its
full ten minutes, watching `docker compose logs -f espocrm` and not the browser.

## 11. Out of scope

- Do not configure SMTP, an outbound email account or an IMAP mailbox. Each needs credentials
  from a mail provider the user has not been asked for, and the CRM runs without them.
- Do not install Advanced Pack, Sales Pack or any other extension. Those are paid products from
  EspoCRM's vendor, installed through the admin panel, not here.
- Do not set `ESPOCRM_DATABASE_PLATFORM` to `Postgresql`. It is fixed at install time.
- Do not enable LDAP, OIDC or two-factor authentication. Each changes how the only account here
  signs in, which is not a thing to do before a backup has been proven.
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 EspoCRM 10.0.3 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. EspoCRM writes `<DOMAIN>` into its own configuration during its first
start and builds every link it puts in an email out of it, so pick the hostname you intend to
keep. Set aside an unhurried afternoon: this is four containers, a database and a first backup,
and the single most common way it goes wrong is deciding it has failed while it is still
starting.

## 1. Preflight

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

You should see: at least `2048` MB available, at least `10` 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,
and run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 2048 MB of RAM is
the other stopper: PHP, a job daemon, a websocket process and MariaDB share this box, and the
one that gets killed under memory pressure is usually the database, which looks like data loss
rather than like a small server.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/espocrm /srv/espocrm/backups
sudo install -d -m 700 /srv/espocrm/db
sudo install -d -m 755 /srv/espocrm/data /srv/espocrm/custom /srv/espocrm/client-custom
ls -la /srv/espocrm
```

You should see: `backups` owned by you, `db` at `drwx------` owned by root, and `data`, `custom`
and `client-custom` at `drwxr-xr-x` owned by root.

If you do not: leave all four owned by root on purpose. The EspoCRM container chowns its three
directories to its web-server user during the first start, and MariaDB chowns its own to the uid
it runs as. A directory you have already chowned to yourself is how each of them fails to
initialise. `data` is the one that matters after today: the configuration file, the logs and
every attachment anybody uploads live in it.

## 3. Secrets

Three secrets, all generated here on the server: the password EspoCRM connects to its database
with, MariaDB's own root password, and the administrator password EspoCRM sets during its first
start. Hex for the two that travel inside a connection string, base64 for the one you will type.
Replace `<DOMAIN>` on the first two lines with your real hostname before you paste.

```bash
umask 077
cat > /srv/espocrm/.env <<EOF
ESPOCRM_SITE_URL=https://<DOMAIN>
ESPOCRM_WEB_SOCKET_URL=wss://<DOMAIN>/ws
ESPOCRM_DATABASE_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ESPOCRM_ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/espocrm/.env
umask 022
ls -l /srv/espocrm/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Read the administrator
password once with `sudo grep ESPOCRM_ADMIN_PASSWORD /srv/espocrm/.env` and put it in your
password manager: it is the only account this install has.

Do not paste that file, any of those three secrets, or any command output containing them into
this chat window. The other tab never sees these values; this one will hand them to a third
party unless you keep them out.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens when
the lines are pasted separately into different shells. Run `chmod 600 /srv/espocrm/.env` and
carry on. If the file already existed from an earlier attempt, this block has now replaced all
three secrets, which is harmless before the database exists and a problem afterwards: MariaDB
keeps the password it was created with, so a changed one on an existing volume shows up as a
connection failure in the EspoCRM log rather than as anything about passwords. One more thing to
know now: the administrator password is applied once, during the first start. Editing this file
later changes nothing, and the change is made inside the CRM instead.

## 4. compose.yml

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

```bash
cat > /srv/espocrm/compose.yml <<'EOF'
# EspoCRM · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.espocrm.com/administration/docker/installation/
#   caddy topology ..... https://docs.espocrm.com/administration/docker/caddy/
#   entrypoint script .. https://github.com/espocrm/espocrm-docker/blob/master/docker-entrypoint.sh
#   jobs and the daemon  https://docs.espocrm.com/administration/jobs/
#
# Four services. Only espocrm answers a browser: it is Apache plus PHP. The
# daemon runs the job queue, where notification email, mass mailing, inbound
# mail checking and cleanup happen, so a CRM without it looks healthy and does
# nothing on schedule. The websocket carries live updates, MariaDB the rows.
# Digests read from Docker Hub on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: espocrm

# The three EspoCRM services run one image over one set of directories, which
# upstream does with volumes_from. Compose ignores x- keys, so the pin below
# covers all three.
x-espocrm: &espocrm
  image: espocrm/espocrm:10.0.3@sha256:a2664ea087c2cbe2dc4bf3306c56b985402c19c2e758b39463742fae14dca513
  restart: unless-stopped
  volumes:
    - /srv/espocrm/data:/var/www/html/data
    - /srv/espocrm/custom:/var/www/html/custom
    - /srv/espocrm/client-custom:/var/www/html/client/custom

services:
  espocrm-db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: espocrm
      MARIADB_USER: espocrm
      MARIADB_PASSWORD: ${ESPOCRM_DATABASE_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    volumes:
      - /srv/espocrm/db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 20s
      start_period: 30s
      timeout: 10s
      retries: 6
    # No `ports:`: 3306 is reachable only from the other containers.

  espocrm:
    <<: *espocrm
    environment:
      ESPOCRM_DATABASE_HOST: espocrm-db
      ESPOCRM_DATABASE_USER: espocrm
      ESPOCRM_DATABASE_PASSWORD: ${ESPOCRM_DATABASE_PASSWORD}
      ESPOCRM_ADMIN_USERNAME: admin
      ESPOCRM_ADMIN_PASSWORD: ${ESPOCRM_ADMIN_PASSWORD}
      ESPOCRM_SITE_URL: ${ESPOCRM_SITE_URL}
    healthcheck:
      # First start installs and builds the schema: minutes, not seconds.
      test: ["CMD", "bin/command", "app-check"]
      interval: 30s
      start_period: 180s
      timeout: 20s
      retries: 5
    ports:
      # Loopback only: the host's Caddy is the only thing reaching 8128.
      - "127.0.0.1:8128:80"
    depends_on:
      espocrm-db:
        condition: service_healthy

  espocrm-daemon:
    <<: *espocrm
    entrypoint: docker-daemon.sh
    depends_on:
      espocrm:
        condition: service_healthy
    # No healthcheck, no `ports:`: app-check reads the shared config, not the
    # job loop, so container state is the honest signal here.

  espocrm-websocket:
    <<: *espocrm
    entrypoint: docker-websocket.sh
    environment:
      # Written into the config all three share, which is how the app container
      # learns where to publish notifications.
      ESPOCRM_CONFIG_USE_WEB_SOCKET: "true"
      ESPOCRM_CONFIG_WEB_SOCKET_URL: ${ESPOCRM_WEB_SOCKET_URL}
      ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN: "tcp://*:7777"
      ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN: "tcp://espocrm-websocket:7777"
    ports:
      # Loopback only: Caddy sends the /ws route here and nothing else can.
      - "127.0.0.1:8228:8080"
    depends_on:
      espocrm:
        condition: service_healthy
EOF
cd /srv/espocrm && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/espocrm/compose.yml` and paste again in one go. A complaint about an
undefined variable means step 3 did not write `.env`, or you are not in `/srv/espocrm`, which is
the directory `docker compose` reads it from. Nothing in this file is a secret: every `${...}` is
looked up in `.env` at start time.

## 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-espocrm
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# EspoCRM · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.espocrm.com/administration/docker/caddy/ 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. That hostname is
# also ESPOCRM_SITE_URL in .env, which EspoCRM writes into its own config on
# first start and builds every link it mails out of.

<DOMAIN> {
	# A JavaScript bundle and a JSON API, both worth compressing.
	encode zstd gzip

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

	# Live notifications ride a WebSocket on a container of its own, at the
	# exact path EspoCRM is told to dial. reverse_proxy upgrades the connection
	# and forwards Host, X-Forwarded-For and X-Forwarded-Proto already.
	reverse_proxy /ws 127.0.0.1:8228

	# 8128 is the loopback port compose publishes for Apache. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8128
}
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-espocrm /etc/caddy/Caddyfile`, reload,
and paste again. The two `reverse_proxy` lines are both needed and their order matters: the one
with `/ws` in front of it is a path matcher, so it takes only that one request, the WebSocket
that carries live notifications, and everything else falls through to Apache on 8128. Caddy
requests the certificate on the first real request to the hostname and renews it on its own.

## 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 `8128`, `8228` or `3306`.

If you do not: delete anything for the application ports with `sudo ufw delete allow 8128`. Both
8128 and 8228 are bound to 127.0.0.1 by the compose file, so Caddy reaches them and nothing else
can, and MariaDB publishes no host port at all, which is why 3306 never appears. 80/tcp answers
the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and 443/udp is HTTP/3,
which Caddy offers by default. `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 any further.

## 7. Start and verify

The first start is slow, and that is correct rather than broken. The app container runs the
installer and builds the schema, which takes minutes on a small box, and the daemon and the
websocket wait on its health check before they start at all.

```bash
cd /srv/espocrm
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>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/ | grep -o '<title>EspoCRM</title>'
curl -sS -o /dev/null -w '%{http_code}\n' -u admin:password https://<DOMAIN>/api/v1/App/user
docker compose exec -T espocrm bin/command config:get useWebSocket
docker compose ps
```

You should see, in order: the loop counting `502` for a while and then reaching `200`,
`<title>EspoCRM</title>`, then `401`, then `true`, then four services listed with `espocrm-db`
and `espocrm` marked healthy.

If you do not: the `401` is the one worth understanding. Upstream's image falls back to a
built-in default administrator password when the environment does not set one, and that pair is
the first login anybody scanning the internet will try. `401` means the password step 3 generated
replaced it. Anything other than `401` there, stop and do not go further. If the loop never
reaches `200`, give it the full ten minutes first, then run
`docker compose logs --tail 20 espocrm-db`, because a database that never reports healthy holds
everything else back, and `docker compose logs --tail 40 espocrm` second. A `true` that comes
back as `false` means the websocket container has not started yet, which is usually the same
waiting problem.

The first screen at https://<DOMAIN> is a login panel with a `Username` field, a `Password` field
and a `Log in` button. Sign in as `admin` with the password from
`sudo grep ESPOCRM_ADMIN_PASSWORD /srv/espocrm/.env`, and confirm the CRM loads before you go on.
Four running containers are not success; a CRM you can sign into is.

## 8. First backup and restore

Two artifacts. Every contact, deal and note is a row in MariaDB. The archive holds what rebuilds
the service around those rows: the attachments, the Caddy site block, and the configuration file
that carries the database password.

```bash
cd /srv/espocrm
docker compose exec -T espocrm-db sh -c 'exec mariadb-dump -u espocrm -p"$MARIADB_PASSWORD" --single-transaction espocrm' | gzip > /srv/espocrm/backups/espocrm-db-$(date +%F).sql.gz
sudo tar -czf /srv/espocrm/backups/espocrm-files-$(date +%F).tar.gz -C /srv/espocrm compose.yml .env data custom client-custom -C /etc/caddy Caddyfile
ls -lh /srv/espocrm/backups/
```

You should see: two files, the dump a few dozen kilobytes on a fresh install and the archive a
little larger. Nothing goes offline: `--single-transaction` snapshots a running InnoDB database
consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump` failed
and the shell created the file anyway. Run the dump line without `| gzip` to read the error. Note
what that command does not do: the password is read from inside the database container's own
environment, so it never appears in your shell history or in the host process list, and you
should keep it that way rather than typing it on the command line.

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/espocrm
scp vps:/srv/espocrm/backups/* ~/backups/espocrm/
```

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

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 `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty CRM:

```bash
cd /srv/espocrm
docker compose down
sudo rm -rf /srv/espocrm/db
sudo install -d -m 700 /srv/espocrm/db
docker compose up -d espocrm-db
sleep 45
gunzip -c /srv/espocrm/backups/espocrm-db-$(date +%F).sql.gz | docker compose exec -T espocrm-db sh -c 'exec mariadb -u espocrm -p"$MARIADB_PASSWORD" espocrm'
docker compose up -d
sleep 60
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see: no output from the import, then `200` from the last command, and your login still
works.

If you do not: `Access denied for user 'espocrm'` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Keep both files together forever:
the archive carries `data/config-internal.php`, and a database restored without it comes back as
rows nothing knows how to open.

## 9. Updating later

New versions are listed at https://github.com/espocrm/espocrm/releases. Take both backup
artifacts first, then edit the `espocrm/espocrm` image line in /srv/espocrm/compose.yml to the new
tag and its digest. It is one line for three services, which is what the `x-espocrm` block at the
top of the file is for.

```bash
cd /srv/espocrm
docker compose pull
docker compose up -d
docker compose logs --tail 30 espocrm
```

You should see: migration output, then the server starting, and no container restarting in a
loop.

If you do not: EspoCRM refuses to start when a customization or an extension is incompatible with
the new release, and it names the version to go back to in that log. Put the old tag and digest
back, run the same three commands, and deal with the customization before trying again. Either
way, re-run the five checks from step 7 before you call the update done.

## 10. What will probably go wrong

You will think it failed while it was still working. `docker compose up -d` returns in seconds,
https://<DOMAIN> answers `502` for the next two or three minutes while the app container installs
itself and builds the schema, and `docker compose ps` shows the daemon and the websocket as
created rather than running, because both wait on a health check that has not passed yet. I read
that as a broken install and had my hand on `docker compose down` before the login page appeared.
Give step 7's loop its full ten minutes, and watch `docker compose logs -f espocrm` rather than
the browser.

## 11. Out of scope

- Do not configure SMTP, an outbound email account or an IMAP mailbox. Each needs credentials
  from a mail provider you have not been asked for, and the CRM runs without them.
- Do not install Advanced Pack, Sales Pack or any other extension. Those are paid products from
  EspoCRM's vendor, installed through the admin panel, not here.
- Do not set `ESPOCRM_DATABASE_PLATFORM` to `Postgresql`. It is fixed at install time.
- Do not enable LDAP, OIDC or two-factor authentication. Each changes how the only account here
  signs in, which is not a thing to do before a backup has been proven.

354 lines · 16,369 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 EspoCRM 10.0.3, with the MariaDB it stores every record in, under ~/selfhost/espocrm,
answering at http://localhost:8128.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install. A CRM is a
shared address book, and this one answers at http://localhost:8128, which means "this computer"
wherever it is typed: no colleague and not even their own phone can open it. The background jobs
run only while this machine is awake, so a laptop closed at 6pm is a CRM doing nothing overnight.
They get a complete sales database for one person.

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. EspoCRM and MariaDB need 2048 MB of RAM
available and 10 GB free on the home disk, and both images publish amd64 and arm64. On macOS and
Windows the figure printed is the host's, out of which Docker Desktop takes its own. If RAM is
under 2048 MB or free disk under 10 GB, print both numbers 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/espocrm/data ~/selfhost/espocrm/custom ~/selfhost/espocrm/client-custom ~/selfhost/espocrm/backups
ls -la ~/selfhost/espocrm
```

Assert: `ls -la` shows all four, owned by the user. The EspoCRM container chowns the first three
to its web-server user on first start, directly on Linux and through Docker Desktop's file
sharing elsewhere, so there is no ownership command here. There is no database folder: MariaDB
chowns its data directory to a uid of its own, so step 5 puts that in a volume Docker manages.

## 4. Secrets

Three: the password EspoCRM connects to its database with, MariaDB's root password, and the
administrator password EspoCRM sets on first start. Generate all three here, print none, keep
them out of your summary and out of any log line.

```bash
umask 077
cat > ~/selfhost/espocrm/.env <<EOF
ESPOCRM_DATABASE_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
ESPOCRM_ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 ~/selfhost/espocrm/.env
umask 022
ls -l ~/selfhost/espocrm/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these lines run the same on all three
systems. Left unset, upstream's entrypoint falls back to a built-in default for the admin and the
database account, warns in the log and starts anyway, so this block closes that door and step 7
proves it closed. The administrator password applies once, on first start: editing this file
later changes nothing, the change is made inside the CRM.

On Windows those mode bits are advisory. NTFS does not enforce them, and the real boundary is the
user's own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/espocrm/compose.yml <<'EOF'
# EspoCRM · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.espocrm.com/administration/docker/installation/
#   entrypoint script .. https://github.com/espocrm/espocrm-docker/blob/master/docker-entrypoint.sh
#   jobs and the daemon  https://docs.espocrm.com/administration/jobs/
#
# Four services on the computer you are sitting at, every application path
# relative to ~/selfhost/espocrm/ so one file works on macOS, Linux and Windows.
# The database is a named volume, not a bind mount: the MariaDB image chowns
# /var/lib/mysql to a uid Docker Desktop's Windows file sharing cannot grant on
# a home-directory bind mount. Only espocrm answers a browser, and the websocket
# is dialled at 8228 direct, there being no proxy to route /ws. Digests read
# from Docker Hub on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: espocrm

# The three EspoCRM services run one image over one set of directories, which
# upstream does with volumes_from. Compose ignores x- keys, so the pin below
# covers all three.
x-espocrm: &espocrm
  image: espocrm/espocrm:10.0.3@sha256:a2664ea087c2cbe2dc4bf3306c56b985402c19c2e758b39463742fae14dca513
  restart: unless-stopped
  volumes:
    - ./data:/var/www/html/data
    - ./custom:/var/www/html/custom
    - ./client-custom:/var/www/html/client/custom

services:
  espocrm-db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: espocrm
      MARIADB_USER: espocrm
      MARIADB_PASSWORD: ${ESPOCRM_DATABASE_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    volumes:
      - espocrm-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 20s
      start_period: 30s
      timeout: 10s
      retries: 6
    # No `ports:`: 3306 is reachable only from the other containers.

  espocrm:
    <<: *espocrm
    environment:
      ESPOCRM_DATABASE_HOST: espocrm-db
      ESPOCRM_DATABASE_USER: espocrm
      ESPOCRM_DATABASE_PASSWORD: ${ESPOCRM_DATABASE_PASSWORD}
      ESPOCRM_ADMIN_USERNAME: admin
      ESPOCRM_ADMIN_PASSWORD: ${ESPOCRM_ADMIN_PASSWORD}
      ESPOCRM_SITE_URL: http://localhost:8128
    healthcheck:
      # First start installs and builds the schema: minutes, not seconds.
      test: ["CMD", "bin/command", "app-check"]
      interval: 30s
      start_period: 180s
      timeout: 20s
      retries: 5
    ports:
      # Loopback only: no other device on the wifi can reach 8128.
      - "127.0.0.1:8128:80"
    depends_on:
      espocrm-db:
        condition: service_healthy

  espocrm-daemon:
    <<: *espocrm
    entrypoint: docker-daemon.sh
    depends_on:
      espocrm:
        condition: service_healthy
    # No healthcheck, no `ports:`: app-check reads the shared config, not the
    # job loop, so container state is the honest signal here.

  espocrm-websocket:
    <<: *espocrm
    entrypoint: docker-websocket.sh
    environment:
      # Written into the config all three share, which is how the app container
      # learns where to publish notifications.
      ESPOCRM_CONFIG_USE_WEB_SOCKET: "true"
      ESPOCRM_CONFIG_WEB_SOCKET_URL: ws://localhost:8228
      ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN: "tcp://*:7777"
      ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN: "tcp://espocrm-websocket:7777"
    ports:
      # Loopback only: the browser on this computer opens this directly.
      - "127.0.0.1:8228:8080"
    depends_on:
      espocrm:
        condition: service_healthy

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

Assert: that prints `compose OK`. Every `${...}` comes from step 4's `.env`, which
`docker compose` reads from this directory on its own.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so the login form and the session cookie behave
  as over TLS.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8128 and 8228 bind to 127.0.0.1, this computer only: not the user's phone, not a laptop on the
same wifi, not anyone on the internet. Confirm that:

```bash
grep -n '127.0.0.1' ~/selfhost/espocrm/compose.yml
```

Assert: two lines, `- "127.0.0.1:8128:80"` and `- "127.0.0.1:8228:8080"`. MariaDB publishes no
host port, so 3306 cannot be there.

## 7. Start and verify

The first start is slow and correct: the app container installs itself and builds the schema
while the other two wait on its health check.

```bash
cd ~/selfhost/espocrm
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:8128/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8128/ | grep -o '<title>EspoCRM</title>'
curl -sS -o /dev/null -w '%{http_code}\n' -u admin:password http://localhost:8128/api/v1/App/user
docker compose exec -T espocrm bin/command config:get useWebSocket
docker compose ps
```

Assert all five and print what you received for each. The loop ends on `200`, and it deserves its
full ten minutes. The second prints `<title>EspoCRM</title>`. The third prints `401`, the security
assert here: `admin` with upstream's built-in default is the first login anyone tries, and `401`
proves step 4 replaced it. The fourth prints `true`, which happens only once the websocket
container has written to the config all three share. The fifth lists four services, `espocrm-db`
and `espocrm` healthy. If any miss, stop, run `docker compose logs --tail 40 espocrm`, then
`--tail 20 espocrm-db`, and name the cause: a database that never reports healthy points at step
4. On `port is already allocated`, find what holds 8128 or 8228
(`lsof -nP -iTCP:8128 -sTCP:LISTEN`, or `netstat -ano | findstr :8128` on Windows) and stop until
the user frees it. A running container is not success.

The first screen at http://localhost:8128 is a login panel with a `Username` field, a `Password`
field and a `Log in` button.

STOP: tell the user to read their password with
`grep ESPOCRM_ADMIN_PASSWORD ~/selfhost/espocrm/.env`, put it in their password manager, sign in
as `admin`, and confirm the CRM loads. Wait. Do not continue until they confirm.

## 8. First backup and restore

Two artifacts: the rows, and the files that rebuild the service around them.

```bash
cd ~/selfhost/espocrm
docker compose exec -T espocrm-db sh -c 'exec mariadb-dump -u espocrm -p"$MARIADB_PASSWORD" --single-transaction espocrm' | gzip > ~/selfhost/espocrm/backups/espocrm-db-$(date +%F).sql.gz
tar -C ~/selfhost/espocrm -czf ~/selfhost/espocrm/backups/espocrm-files-$(date +%F).tar.gz compose.yml .env data custom client-custom
ls -lh ~/selfhost/espocrm/backups/
```

Assert: both exist and both are non-empty. Print both sizes. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database consistently.

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 sync
folder or a USB stick, and copy both there with `cp`. In Git Bash a Windows drive is
`/d/Backups`, not `D:\Backups`. Assert: the user confirms both filenames are there. If they have
nowhere to put them, say plainly this install has no backup yet.

To restore, in this order. `cd ~/selfhost/espocrm`, untar the file archive there first so
compose.yml and .env are back before any container starts, because MariaDB reads its password
from .env the moment it initialises an empty volume. Then `docker compose down -v`, the one place
`-v` belongs because it drops the old volume on purpose, `docker compose up -d espocrm-db`, wait
30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T espocrm-db sh -c 'exec mariadb -u espocrm -p"$MARIADB_PASSWORD" espocrm'`,
then `docker compose up -d`, sign in, check a record is back.

## 9. Updating later

New versions are listed at https://github.com/espocrm/espocrm/releases. Take both backups first,
then edit the `espocrm/espocrm` image line in ~/selfhost/espocrm/compose.yml to the new tag and
digest: one line for three services, which is what `x-espocrm` is for.

```bash
cd ~/selfhost/espocrm
docker compose pull
docker compose up -d
docker compose logs --tail 30 espocrm
```

EspoCRM migrates its own database on the way up and refuses to start if a customization is
incompatible, naming the version to go back to. Watch that log settle, then re-run step 7.

## 10. What will probably go wrong

I rebooted, opened the CRM to check a follow-up, and got a connection error that read like a lost
database. It was not: Docker Desktop had not started with the session, so nothing was listening
on 8128. `restart: unless-stopped` acts only once the Docker daemon is up. Turn on its
start-at-login setting, then after a reboot run `cd ~/selfhost/espocrm && docker compose up -d`
and wait two or three minutes. The quieter half: every scheduled job the daemon owes you is
deferred while this machine sleeps, and they all fire at once when it wakes.

## 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 rebind 8128 to 0.0.0.0 so a phone or a colleague can reach it. That puts a CRM full of
  other people's contact details on every network this computer joins.
- Do not configure SMTP or an IMAP mailbox. Both need credentials from a mail provider the user
  has not been asked for.
- Do not install Advanced Pack, Sales Pack or any other extension. Those are paid products sold
  by EspoCRM's vendor, installed through the admin panel.
compose.local.ymlthe services, pinned · local layout99 lines

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

# EspoCRM · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.espocrm.com/administration/docker/installation/
#   entrypoint script .. https://github.com/espocrm/espocrm-docker/blob/master/docker-entrypoint.sh
#   jobs and the daemon  https://docs.espocrm.com/administration/jobs/
#
# Four services on the computer you are sitting at, every application path
# relative to ~/selfhost/espocrm/ so one file works on macOS, Linux and Windows.
# The database is a named volume, not a bind mount: the MariaDB image chowns
# /var/lib/mysql to a uid Docker Desktop's Windows file sharing cannot grant on
# a home-directory bind mount. Only espocrm answers a browser, and the websocket
# is dialled at 8228 direct, there being no proxy to route /ws. Digests read
# from Docker Hub on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: espocrm

# The three EspoCRM services run one image over one set of directories, which
# upstream does with volumes_from. Compose ignores x- keys, so the pin below
# covers all three.
x-espocrm: &espocrm
  image: espocrm/espocrm:10.0.3@sha256:a2664ea087c2cbe2dc4bf3306c56b985402c19c2e758b39463742fae14dca513
  restart: unless-stopped
  volumes:
    - ./data:/var/www/html/data
    - ./custom:/var/www/html/custom
    - ./client-custom:/var/www/html/client/custom

services:
  espocrm-db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: espocrm
      MARIADB_USER: espocrm
      MARIADB_PASSWORD: ${ESPOCRM_DATABASE_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    volumes:
      - espocrm-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 20s
      start_period: 30s
      timeout: 10s
      retries: 6
    # No `ports:`: 3306 is reachable only from the other containers.

  espocrm:
    <<: *espocrm
    environment:
      ESPOCRM_DATABASE_HOST: espocrm-db
      ESPOCRM_DATABASE_USER: espocrm
      ESPOCRM_DATABASE_PASSWORD: ${ESPOCRM_DATABASE_PASSWORD}
      ESPOCRM_ADMIN_USERNAME: admin
      ESPOCRM_ADMIN_PASSWORD: ${ESPOCRM_ADMIN_PASSWORD}
      ESPOCRM_SITE_URL: http://localhost:8128
    healthcheck:
      # First start installs and builds the schema: minutes, not seconds.
      test: ["CMD", "bin/command", "app-check"]
      interval: 30s
      start_period: 180s
      timeout: 20s
      retries: 5
    ports:
      # Loopback only: no other device on the wifi can reach 8128.
      - "127.0.0.1:8128:80"
    depends_on:
      espocrm-db:
        condition: service_healthy

  espocrm-daemon:
    <<: *espocrm
    entrypoint: docker-daemon.sh
    depends_on:
      espocrm:
        condition: service_healthy
    # No healthcheck, no `ports:`: app-check reads the shared config, not the
    # job loop, so container state is the honest signal here.

  espocrm-websocket:
    <<: *espocrm
    entrypoint: docker-websocket.sh
    environment:
      # Written into the config all three share, which is how the app container
      # learns where to publish notifications.
      ESPOCRM_CONFIG_USE_WEB_SOCKET: "true"
      ESPOCRM_CONFIG_WEB_SOCKET_URL: ws://localhost:8228
      ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN: "tcp://*:7777"
      ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN: "tcp://espocrm-websocket:7777"
    ports:
      # Loopback only: the browser on this computer opens this directly.
      - "127.0.0.1:8228:8080"
    depends_on:
      espocrm:
        condition: service_healthy

volumes:
  espocrm-db:

agent-readable mirror: /self-host/pipedrive.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, pinned95 lines

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

# EspoCRM · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.espocrm.com/administration/docker/installation/
#   caddy topology ..... https://docs.espocrm.com/administration/docker/caddy/
#   entrypoint script .. https://github.com/espocrm/espocrm-docker/blob/master/docker-entrypoint.sh
#   jobs and the daemon  https://docs.espocrm.com/administration/jobs/
#
# Four services. Only espocrm answers a browser: it is Apache plus PHP. The
# daemon runs the job queue, where notification email, mass mailing, inbound
# mail checking and cleanup happen, so a CRM without it looks healthy and does
# nothing on schedule. The websocket carries live updates, MariaDB the rows.
# Digests read from Docker Hub on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: espocrm

# The three EspoCRM services run one image over one set of directories, which
# upstream does with volumes_from. Compose ignores x- keys, so the pin below
# covers all three.
x-espocrm: &espocrm
  image: espocrm/espocrm:10.0.3@sha256:a2664ea087c2cbe2dc4bf3306c56b985402c19c2e758b39463742fae14dca513
  restart: unless-stopped
  volumes:
    - /srv/espocrm/data:/var/www/html/data
    - /srv/espocrm/custom:/var/www/html/custom
    - /srv/espocrm/client-custom:/var/www/html/client/custom

services:
  espocrm-db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: espocrm
      MARIADB_USER: espocrm
      MARIADB_PASSWORD: ${ESPOCRM_DATABASE_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    volumes:
      - /srv/espocrm/db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 20s
      start_period: 30s
      timeout: 10s
      retries: 6
    # No `ports:`: 3306 is reachable only from the other containers.

  espocrm:
    <<: *espocrm
    environment:
      ESPOCRM_DATABASE_HOST: espocrm-db
      ESPOCRM_DATABASE_USER: espocrm
      ESPOCRM_DATABASE_PASSWORD: ${ESPOCRM_DATABASE_PASSWORD}
      ESPOCRM_ADMIN_USERNAME: admin
      ESPOCRM_ADMIN_PASSWORD: ${ESPOCRM_ADMIN_PASSWORD}
      ESPOCRM_SITE_URL: ${ESPOCRM_SITE_URL}
    healthcheck:
      # First start installs and builds the schema: minutes, not seconds.
      test: ["CMD", "bin/command", "app-check"]
      interval: 30s
      start_period: 180s
      timeout: 20s
      retries: 5
    ports:
      # Loopback only: the host's Caddy is the only thing reaching 8128.
      - "127.0.0.1:8128:80"
    depends_on:
      espocrm-db:
        condition: service_healthy

  espocrm-daemon:
    <<: *espocrm
    entrypoint: docker-daemon.sh
    depends_on:
      espocrm:
        condition: service_healthy
    # No healthcheck, no `ports:`: app-check reads the shared config, not the
    # job loop, so container state is the honest signal here.

  espocrm-websocket:
    <<: *espocrm
    entrypoint: docker-websocket.sh
    environment:
      # Written into the config all three share, which is how the app container
      # learns where to publish notifications.
      ESPOCRM_CONFIG_USE_WEB_SOCKET: "true"
      ESPOCRM_CONFIG_WEB_SOCKET_URL: ${ESPOCRM_WEB_SOCKET_URL}
      ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN: "tcp://*:7777"
      ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBMISSION_DSN: "tcp://espocrm-websocket:7777"
    ports:
      # Loopback only: Caddy sends the /ws route here and nothing else can.
      - "127.0.0.1:8228:8080"
    depends_on:
      espocrm:
        condition: service_healthy
Caddyfilethe hostname and TLS32 lines

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

# EspoCRM · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.espocrm.com/administration/docker/caddy/ 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. That hostname is
# also ESPOCRM_SITE_URL in .env, which EspoCRM writes into its own config on
# first start and builds every link it mails out of.

<DOMAIN> {
	# A JavaScript bundle and a JSON API, both worth compressing.
	encode zstd gzip

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

	# Live notifications ride a WebSocket on a container of its own, at the
	# exact path EspoCRM is told to dial. reverse_proxy upgrades the connection
	# and forwards Host, X-Forwarded-For and X-Forwarded-Proto already.
	reverse_proxy /ws 127.0.0.1:8228

	# 8128 is the loopback port compose publishes for Apache. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8128
}
install.shthe same install, no agent162 lines

authored from upstream docs, never pasted · 7,654 bytes

#!/usr/bin/env bash
# EspoCRM · 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=crm.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.espocrm.com/administration/docker/installation/
#   https://docs.espocrm.com/administration/docker/caddy/
#   https://docs.espocrm.com/administration/backup-and-restore/
#   https://github.com/espocrm/espocrm-docker/blob/master/docker-entrypoint.sh
#
# Three secrets are generated here, on this machine: the password EspoCRM
# connects to MariaDB with, MariaDB's own root password, and the administrator
# password EspoCRM sets during its first start. All three go into
# /srv/espocrm/.env with mode 600 and none of them is ever printed.
#
# DOMAIN_HOST becomes ESPOCRM_SITE_URL, which EspoCRM writes into its own
# configuration on first start and builds every link it mails out of.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/espocrm}"
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. crm.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; PHP, the daemon, the websocket and MariaDB want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 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 ----------------------------------------------------
#
# Root keeps the application directories: the EspoCRM container chowns them to
# its web-server user on the way up, and MariaDB chowns its own to the uid it
# runs as. A directory pre-owned by the login user is how each of them fails.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/db"
sudo install -d -m 755 "$APP_DIR/data" "$APP_DIR/custom" "$APP_DIR/client-custom"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex for the two that travel inside a connection string, base64 for the one a
# human types. Read the administrator password later with
#   sudo grep ESPOCRM_ADMIN_PASSWORD /srv/espocrm/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		ESPOCRM_SITE_URL=https://${DOMAIN_HOST}
		ESPOCRM_WEB_SOCKET_URL=wss://${DOMAIN_HOST}/ws
		ESPOCRM_DATABASE_PASSWORD=$(openssl rand -hex 32)
		MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
		ESPOCRM_ADMIN_PASSWORD=$(openssl rand -base64 24)
	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-espocrm"
	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: three open, and none of 8128, 8228 or 3306 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; 8128, 8228 and 3306 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 first start is slow: the app container runs the installer and builds the
# schema, and the daemon and the websocket wait on its health check.

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/" | grep -q '<title>EspoCRM</title>' \
	|| die "the page at https://${DOMAIN_HOST}/ is not EspoCRM. Check: docker compose logs --tail 40 espocrm"

# The built-in default administrator password must not work. Upstream's
# entrypoint falls back to it when the environment does not set one.
default_auth="$(curl -sS -o /dev/null -w '%{http_code}' -u admin:password "https://${DOMAIN_HOST}/api/v1/App/user" || true)"
[ "$default_auth" = "401" ] || die "the built-in default login returned ${default_auth}, not 401. Stop and investigate."

# The websocket container has started and written to the shared configuration.
ws="$(docker compose exec -T espocrm bin/command config:get useWebSocket | tr -d '[:space:]')"
[ "$ws" = "true" ] || die "useWebSocket is ${ws:-empty}. Check: docker compose logs --tail 30 espocrm-websocket"

docker compose ps

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

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T espocrm-db sh -c 'exec mariadb-dump -u espocrm -p"$MARIADB_PASSWORD" --single-transaction espocrm' \
	| gzip > "$APP_DIR/backups/espocrm-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/espocrm-files-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env data custom client-custom -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/espocrm-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/espocrm-files-${STAMP}.tar.gz" ] || die "the file archive is empty"

cat <<-DONE

	EspoCRM is answering at https://${DOMAIN_HOST}/

	  1. Sign in as admin. Your password is in $APP_DIR/.env, mode 600. Read it
	     with
	       sudo grep ESPOCRM_ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here, and it
	     is the only account this install has.
	  2. That password was applied once, during the first start. Editing .env
	     later changes nothing; change it inside the CRM instead.
	  3. Four containers are running. espocrm serves the pages, espocrm-daemon
	     runs the job queue that notification email and inbound mail need,
	     espocrm-websocket carries live notifications, espocrm-db holds every
	     record. If the daemon stops, the CRM keeps answering and quietly stops
	     doing anything on a schedule.
	  4. First backup written to $APP_DIR/backups: a database dump and a file
	     archive. They belong together, because the archive carries
	     data/config-internal.php. Both are on the same disk as the data, which
	     is not a backup. Copy them somewhere else tonight.

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 Pipedrive.

  • This is a stack, not an app. Four containers: the web application, a daemon that works the job queue, a websocket process for live updates, and MariaDB. The daemon is the one nobody notices until it stops, because the CRM keeps answering pages perfectly while notification email, mass mailing and inbound mail quietly stop happening.
  • No mail, and mail is half of what a CRM does. This install configures no SMTP and no IMAP, so there is no email-to-case, no campaign sending, no notification mail and no password-reset link. Everything else works, and you add a mail provider on the day you are ready to own deliverability.
  • The administrator account is the whole recovery story. It is created once during the first start, with a password generated on the server, and with no mail configured the Forgot Password link has nothing to send. Lose that password and the way back in is editing the database by hand.
  • You own a MariaDB. Every contact, deal, note and attachment record is a row in it, and the database dump plus the file archive are one backup in two pieces, because data/config-internal.php holds the credentials those rows are read with.
  • Lead enrichment, web-visitor tracking, e-signatures and phone support are what the upper Pipedrive tiers actually sell, and none of them is here. EspoCRM's own vendor sells extension packs for some of that ground, priced separately from the AGPL release you install.

Where this came from

“Jobs are tasks executing in the background. They handle operations like sending notifications, mass mailing, syncing, cleanup, etc.”

  • Upstream's own Docker Compose stack for a Caddy install is four services: the EspoCRM web container, a daemon container running the job queue, a websocket container, and MariaDB. source
  • ESPOCRM_ADMIN_USERNAME, ESPOCRM_ADMIN_PASSWORD, ESPOCRM_DATABASE_PASSWORD and ESPOCRM_SITE_URL are one-time installation environment variables, and upstream calls the site URL very important for normal operating of EspoCRM. source
  • Left unset, the image falls back to a built-in default for both the administrator and the database account, prints a warning that insecure default credentials were detected, and starts anyway. source
  • Email fetching does not work at all unless the background job runner is configured, which is what the daemon container in this install is for. source
  • The API accepts HTTP Basic authentication and answers 401 when the username or password is wrong, which is the check this install uses to prove the built-in default password no longer works. source

Questions people actually ask

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

  • Can I self-host Pipedrive?

    Not Pipedrive 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 EspoCRM. A whole sales CRM on your own hostname: pipelines, contacts, activities and a REST API, with nobody counting seats. 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 240 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 Pipedrive?

    EspoCRM. A whole sales CRM on your own hostname: pipelines, contacts, activities and a REST API, with nobody counting seats. The closest thing to a whole Pipedrive you can run yourself: pipelines and deal stages, contacts and organisations, activities with reminders, quotes and documents, a documented REST API with an OpenAPI spec, and no seat counter anywhere. AGPL-3.0, released continuously, with an official Docker image its own vendor maintains. What you take on is real: four containers, a MariaDB you back up, and a background daemon that has to keep running or the CRM keeps answering while silently doing nothing on a schedule. EspoCRM is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Pipedrive?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. EspoCRM itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Pipedrive Growth, $147/mo — $1,764 a year, 3 seats 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 EspoCRM install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run EspoCRM 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 EspoCRM 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: Only this computer can open the CRM, so a colleague or your own phone gets a connection error, and the background job container stops working through the queue every time 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-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.