CRM
    MariaDB

    Deploy EspoCRM on a VPS

    Self-host EspoCRM on a RamNode VPS with MariaDB, Docker Compose, scheduled jobs, WebSockets, Caddy HTTPS, and backups.

    EspoCRM is an open-source customer relationship manager. This guide deploys MariaDB, the web app, a scheduled-job daemon and a WebSocket server with Docker Compose behind Caddy HTTPS.

    Prerequisites

    • RamNode KVM VPS running Ubuntu 24.04 LTS (26.04 LTS also works), 2 GB RAM, 1–2 vCPUs and 40 GB disk for a small team.
    • Domain such as crm.example.com with A record (and AAAA if using IPv6) pointing to the VPS.
    • Initial root SSH access and SMTP relay credentials for outbound email.

    Replace all example domains and passwords. See DNS and Docker Compose.

    Prepare the server

    Run as root. Verify the deploy SSH login in a second terminal before disabling root login:

    shell
    apt update && apt -y full-upgrade
    apt install -y rsync ufw unattended-upgrades
    timedatectl set-timezone UTC
    adduser deploy
    usermod -aG sudo deploy
    rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

    Once verified, run as root:

    shell
    cat > /etc/ssh/sshd_config.d/99-hardening.conf <<'CONFIG'
    PermitRootLogin no
    PasswordAuthentication no
    CONFIG
    sshd -t && systemctl restart ssh
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw enable
    fallocate -l 2G /swapfile
    chmod 600 /swapfile
    mkswap /swapfile && swapon /swapfile
    echo '/swapfile none swap sw 0 0' >> /etc/fstab
    dpkg-reconfigure -plow unattended-upgrades

    Allow a custom SSH port before enabling UFW. Docker can bypass UFW, so all app ports below bind only to localhost. See cloud firewall.

    Install Docker Engine and Compose

    As deploy:

    shell
    sudo apt install -y ca-certificates curl
    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL https://download.docker.com/linux/ubuntu/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/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt update
    sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    sudo usermod -aG docker deploy
    sudo tee /etc/docker/daemon.json > /dev/null <<'JSON'
    {"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}
    JSON
    sudo systemctl restart docker

    Log out and back in; check docker run --rm hello-world and docker compose version.

    Deploy EspoCRM with Docker Compose

    shell
    sudo mkdir -p /opt/espocrm && sudo chown deploy:deploy /opt/espocrm
    cd /opt/espocrm
    openssl rand -hex 24  # run three times for DB root, DB user and admin passwords

    Create /opt/espocrm/.env with distinct generated values:

    shell
    DB_ROOT_PASSWORD=change-me-root
    DB_PASSWORD=change-me-db
    ADMIN_USERNAME=admin
    ADMIN_PASSWORD=change-me-admin
    DOMAIN=crm.example.com
    shell
    chmod 600 /opt/espocrm/.env

    Create /opt/espocrm/compose.yml:

    shell
    services:
      espocrm-db:
        image: mariadb:11.4
        restart: unless-stopped
        environment:
          MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
          MARIADB_DATABASE: espocrm
          MARIADB_USER: espocrm
          MARIADB_PASSWORD: ${DB_PASSWORD}
        volumes:
          - espocrm-db:/var/lib/mysql
        healthcheck:
          test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
          interval: 20s
          timeout: 5s
          retries: 3
    
      espocrm:
        image: espocrm/espocrm:latest
        restart: unless-stopped
        environment:
          ESPOCRM_DATABASE_PLATFORM: Mysql
          ESPOCRM_DATABASE_HOST: espocrm-db
          ESPOCRM_DATABASE_USER: espocrm
          ESPOCRM_DATABASE_PASSWORD: ${DB_PASSWORD}
          ESPOCRM_ADMIN_USERNAME: ${ADMIN_USERNAME}
          ESPOCRM_ADMIN_PASSWORD: ${ADMIN_PASSWORD}
          ESPOCRM_SITE_URL: https://${DOMAIN}
        volumes:
          - espocrm:/var/www/html
        depends_on:
          espocrm-db:
            condition: service_healthy
        ports:
          - "127.0.0.1:8080:80"
    
      espocrm-daemon:
        image: espocrm/espocrm:latest
        restart: unless-stopped
        entrypoint: docker-daemon.sh
        volumes:
          - espocrm:/var/www/html
        depends_on:
          - espocrm
    
      espocrm-websocket:
        image: espocrm/espocrm:latest
        restart: unless-stopped
        entrypoint: docker-websocket.sh
        environment:
          ESPOCRM_CONFIG_USE_WEBSOCKET: "true"
          ESPOCRM_CONFIG_WEBSOCKET_URL: wss://${DOMAIN}/ws
          ESPOCRM_CONFIG_WEBSOCKET_ZERO_M_Q_SUBSCRIBER_DSN: tcp://*:7777
          ESPOCRM_CONFIG_WEBSOCKET_ZERO_M_Q_SUBMISSION_DSN: tcp://espocrm-websocket:7777
        volumes:
          - espocrm:/var/www/html
        depends_on:
          - espocrm
        ports:
          - "127.0.0.1:8081:8080"
    
    volumes:
      espocrm-db:
      espocrm:
    shell
    cd /opt/espocrm
    docker compose config --quiet
    docker compose up -d
    docker compose logs -f espocrm

    First installation may take a couple of minutes. The admin credentials and site URL environment settings apply on first install only; change them later in the Administration panel. Pin the app image to a tested release tag once working.

    Configure Caddy and HTTPS

    shell
    sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
    sudo apt update && sudo apt install -y caddy

    Put this in /etc/caddy/Caddyfile:

    shell
    crm.example.com {
        encode zstd gzip
        handle /ws* {
            reverse_proxy 127.0.0.1:8081
        }
        handle {
            reverse_proxy 127.0.0.1:8080
        }
    }
    shell
    sudo caddy validate --config /etc/caddy/Caddyfile
    sudo systemctl reload caddy

    Caddy obtains a certificate when DNS resolves and ports 80/443 are reachable. See its logs with journalctl -u caddy -f.

    First login and configuration

    Open https://crm.example.com and sign in using the admin credentials from .env. In Administration, configure:

    1. Outbound Emails: authenticated SMTP relay, port 587, TLS and a system From address. Send a test mail. See outgoing SMTP policy.
    2. Settings: confirm the HTTPS Site URL and choose time zone, date format and currency.
    3. Scheduled Jobs: confirm recent runs; the daemon replaces a host crontab.
    4. Authentication: enable two-factor authentication, especially for admins.
    5. Users: create individual user accounts and avoid daily use of the built-in admin.

    If live notifications do not work, check the browser's connection to wss://crm.example.com/ws.

    Backups and restore

    Back up both MariaDB and the espocrm volume, which contains uploads, data/config.php and custom code. Also keep .env and compose.yml. Create /opt/espocrm/backup.sh:

    shell
    #!/usr/bin/env bash
    set -euo pipefail
    cd /opt/espocrm
    TS=$(date +%F-%H%M)
    DEST=/var/backups/espocrm
    mkdir -p "$DEST"
    docker compose exec -T espocrm-db sh -c \
      'mariadb-dump -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction espocrm' \
      | gzip > "$DEST/db-$TS.sql.gz"
    docker run --rm -v espocrm_espocrm:/data:ro -v "$DEST":/backup alpine \
      tar czf "/backup/files-$TS.tar.gz" -C /data .
    cp .env "$DEST/env-$TS"
    cp compose.yml "$DEST/compose-$TS.yml"
    chmod 600 "$DEST"/*
    find "$DEST" -type f -mtime +14 -delete

    The volume name assumes the project directory is /opt/espocrm; confirm with docker volume ls and replace if needed.

    shell
    sudo chmod 700 /opt/espocrm/backup.sh
    sudo /opt/espocrm/backup.sh
    sudo crontab -e
    # Add: 15 3 * * * /opt/espocrm/backup.sh

    Copy both backups off the VPS. See backups. To restore on a fresh server with saved .env and compose.yml, start the stack once so volumes exist; stop all app containers while restoring files:

    shell
    cd /opt/espocrm
    docker compose up -d
    docker compose stop espocrm espocrm-daemon espocrm-websocket
    docker run --rm -v espocrm_espocrm:/data -v /var/backups/espocrm:/backup alpine \
      sh -c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar xzf /backup/files-YYYY-MM-DD-HHMM.tar.gz -C /data'
    gunzip -c /var/backups/espocrm/db-YYYY-MM-DD-HHMM.sql.gz | \
      docker compose exec -T espocrm-db sh -c 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" espocrm'
    docker compose up -d

    Confirm the database and file archives have matching timestamps.

    Updating

    Back up first. Pull the updated image and watch logs:

    shell
    cd /opt/espocrm
    sudo ./backup.sh
    docker compose pull
    docker compose up -d
    docker compose logs -f espocrm

    Do not use the in-app upgrader with Docker: a container restart could overwrite its changes. Upgrade MariaDB major versions deliberately after a backup.

    Troubleshooting

    SymptomCheck
    Caddy certificate failsCheck dig +short crm.example.com, port 80 and journalctl -u caddy -f.
    502 responseCheck docker compose ps and docker compose logs espocrm; installation may still be running.
    Database connection failsInspect docker compose logs espocrm-db and verify the original database password in .env; changing it after first boot does not change the stored DB password.
    Live notifications failConfirm ESPOCRM_CONFIG_WEBSOCKET_URL uses wss://crm.example.com/ws and Caddy proxies /ws* to port 8081.
    Cron not configuredRestart espocrm-daemon and inspect its logs.
    Emails not sentCheck authenticated SMTP settings and send a test from Outbound Emails.
    Links point to old domainChange Site URL under Administration > Settings.