Skip to content

Install on a VM

Marc Pope edited this page Oct 12, 2026 · 8 revisions

Install on a VM (no Docker)

A standard Laravel deployment: nginx + php-fpm + MySQL. There is no installer and no frontend build step — the assets are precompiled and committed.

Examples below assume Ubuntu 22.04/24.04 and /opt/cortendesk. Adjust paths and package names for your distribution.

Requirements

  • PHP 8.3 or newer (8.4 recommended) with pdo_mysql, mbstring, intl, bcmath, xml, curl, zip, gd, opcache
  • Composer
  • MySQL 8 or MariaDB 10.6+
  • nginx (or any Laravel-capable web server)
  • A running open-source RustDesk server (hbbs/hbbr) reachable from your devices
  • For the in-browser client: TLS, and a proxy that bridges wss:// to the hbbs/hbbr WebSocket ports 21118 and 21119
sudo apt update
sudo apt install -y nginx mysql-server git unzip \
  php8.4-fpm php8.4-mysql php8.4-mbstring php8.4-intl php8.4-bcmath \
  php8.4-xml php8.4-curl php8.4-zip php8.4-gd

If your distribution ships an older PHP, add a PPA (Ubuntu: ondrej/php) or use the Docker image instead.

The RustDesk server is a prerequisite, not part of this install. If you do not have one, or yours is only half-working, see RustDesk server in Docker — hbbs and hbbr from scratch, every port, and where the key comes from.

1. Database

CREATE DATABASE cortendesk CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'cortendesk'@'localhost' IDENTIFIED BY 'use-a-real-password';
GRANT ALL PRIVILEGES ON cortendesk.* TO 'cortendesk'@'localhost';
FLUSH PRIVILEGES;

2. Get the code

sudo mkdir -p /opt/cortendesk
sudo chown "$USER" /opt/cortendesk
git clone https://github.com/marcpope/cortendesk.git /opt/cortendesk
cd /opt/cortendesk
composer install --no-dev --optimize-autoloader

main always equals the latest release. To pin a version:

git checkout v1.11.0

3. Configure

cp .env.example .env
php artisan key:generate

Edit .env:

APP_NAME=CortenDesk
APP_ENV=production
APP_DEBUG=false
APP_URL=https://console.example.com

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=cortendesk
DB_USERNAME=cortendesk
DB_PASSWORD=use-a-real-password

# Your RustDesk server
CORTENDESK_ID_SERVER=hbbs.example.com:21116
CORTENDESK_RELAY_SERVER=hbbs.example.com:21117
CORTENDESK_PUBLIC_KEY=<contents of id_ed25519.pub>

# Native web client — wss endpoints your proxy exposes
CORTENDESK_NATIVE_WEBCLIENT=true
CORTENDESK_WS_ID_URL=wss://console.example.com/ws/id
CORTENDESK_WS_RELAY_URL=wss://console.example.com/ws/relay

APP_KEY encrypts the SMTP password, the OIDC client secret and 2FA secrets. Back it up with the database — one without the other is not a usable restore.

Two optional settings since 1.11.0: CORTENDESK_UPDATE_CHECK=false stops the GitHub release check and hides the update badge, and CORTENDESK_NOTIFY_TIMEOUT (default 15 seconds) sets how long scheduled notifications wait for Apprise.

4. Migrate and cache

php artisan migrate --seed
php artisan config:cache
php artisan route:cache
php artisan view:cache

--seed creates admin / changeme. Change it at first sign-in.

5. Permissions

The web server user needs to write two directories and nothing else:

sudo chown -R www-data:www-data /opt/cortendesk/storage /opt/cortendesk/bootstrap/cache
sudo find /opt/cortendesk/storage -type d -exec chmod 775 {} \;

If you run artisan by hand afterwards, run it as www-data, or it will leave root-owned cache files that php-fpm cannot write:

sudo -u www-data php artisan config:cache

6. nginx

server {
    listen 443 ssl http2;
    server_name console.example.com;

    ssl_certificate     /etc/letsencrypt/live/console.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/console.example.com/privkey.pem;

    root /opt/cortendesk/public;
    index index.php;

    # Client installer uploads, up to 512 MB by default (CORTENDESK_DOWNLOADS_MAX_MB)
    client_max_body_size 528m;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
        fastcgi_read_timeout 300s;   # copying and hashing a large upload
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }

    # Web client: bridge wss to the RustDesk server. Browsers refuse ws:// from
    # an HTTPS page, which is the whole reason these two blocks exist.
    location = /ws/id {
        proxy_pass http://127.0.0.1:21118/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }

    location = /ws/relay {
        proxy_pass http://127.0.0.1:21119/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }
}

server {
    listen 80;
    server_name console.example.com;
    return 301 https://$host$request_uri;
}

Point proxy_pass at wherever hbbs/hbbr actually listen — 127.0.0.1 only if they run on this same VM.

sudo nginx -t && sudo systemctl reload nginx

Skip the two /ws/ blocks if you are not using the in-browser client. The console itself works without them.

PHP has its own upload limits. Raise them to match nginx, then restart php-fpm:

; /etc/php/8.4/fpm/conf.d/99-cortendesk.ini
upload_max_filesize = 528M
post_max_size = 528M

See Client downloads.

7. Scheduler

Log retention, pruning and other maintenance run through Laravel's scheduler. Without this they never run.

sudo crontab -u www-data -e
* * * * * cd /opt/cortendesk && php artisan schedule:run >> /dev/null 2>&1

If your PHP lists pcntl_signal in disable_functions (hosting panels often do), use 1.10.1 or later. Earlier releases ran each scheduled task once and then left it locked for a day.

8. Point your devices at it

On each RustDesk client, Settings → Network: ID Server, Relay Server, Key, and API Server = https://console.example.com. Devices appear within about fifteen seconds. The console's Settings screen shows copy-paste values for all four fields.

For unattended deployment, create an API token under Settings → Security and use the client's --assign flag.

Behind a reverse proxy

If something else terminates TLS and forwards to this VM over plain HTTP, the app honours X-Forwarded-* headers already. Set APP_URL to the public HTTPS URL. If your proxy sits outside the private address ranges, set TRUSTED_PROXIES in .env to its address — see Reverse proxy and TLS.

Sizing for larger fleets

Every heartbeat, inventory upload and console page takes a php-fpm worker while it runs. Ubuntu's pool starts with pm.max_children = 5, which a few hundred devices fill: requests queue in nginx and the console stops answering. The Docker image uses 24 since 1.11.0. Set the same in /etc/php/8.4/fpm/pool.d/www.conf if the VM has the memory (about 40 MB per worker):

pm = dynamic
pm.max_children = 24
pm.start_servers = 6
pm.min_spare_servers = 3
pm.max_spare_servers = 12
pm.max_requests = 1000

With MySQL, keep pm.max_children below the database's max_connections. If the php-fpm log shows server reached pm.max_children setting, raise it.

nginx needs enough connections and open files for every device's requests. The image uses 4096 connections per worker and twice that in open files. In /etc/nginx/nginx.conf:

worker_rlimit_nofile 8192;      # top level

events {
    worker_connections 4096;    # Ubuntu ships 768
}

Restart php-fpm and reload nginx afterwards.

Upgrading

cd /opt/cortendesk
sudo -u www-data git pull
sudo -u www-data composer install --no-dev --optimize-autoloader
sudo -u www-data php artisan migrate --force
sudo -u www-data php artisan optimize:clear
sudo -u www-data php artisan config:cache
sudo -u www-data php artisan route:cache
sudo -u www-data php artisan view:cache

Back up the database first. See How to Upgrade.

Backups

mysqldump -u cortendesk -p cortendesk > cortendesk-$(date +%F).sql
cp /opt/cortendesk/.env /somewhere/safe/     # holds APP_KEY

A database backup without APP_KEY cannot decrypt the SMTP password, the OIDC client secret, or anyone's 2FA secret. Keep both.

Migrating from lejianwen/rustdesk-api

php artisan cortendesk:import-lejianwen /path/to/rustdeskapi.db --dry-run
php artisan cortendesk:import-lejianwen /path/to/rustdeskapi.db

Users keep their original password hashes, devices are deduplicated, and address books, share rules and audit history come across. Address-book entry passwords encrypted by the Go API cannot be decrypted and must be re-saved by users.

Troubleshooting

500 on every page. Almost always permissions. Check storage/logs/laravel.log, then confirm storage and bootstrap/cache are owned by www-data.

Changes to .env do nothing. The config is cached. Run sudo -u www-data php artisan config:cache after every edit.

No devices appear. The clients' API Server is not pointing here. Confirm the value in the client, and that this host is reachable from the device.

Web client will not connect. The /ws/ blocks are missing, point at the wrong host, or CORTENDESK_WS_*_URL is not the wss:// URL the browser sees.

Sign-in loops back to the login page, or 419 over HTTP. SESSION_SECURE_COOKIE=true without HTTPS, or an APP_URL that does not match the address in the browser.

Locked out by SSO or 2FA. Set CORTENDESK_OIDC_DISABLED=true in .env, or run sudo -u www-data php artisan cortendesk:2fa-reset <username>.

Notifications stopped and Diagnostics shows no fresh scheduler heartbeat, though cron runs. Before 1.10.1, with pcntl_signal in PHP's disable_functions, scheduled tasks ran once and then stayed locked. Upgrade, then clear the stuck locks once: sudo -u www-data php artisan schedule:clear-cache.

Uploads of large installers fail with 413 or an empty error. nginx client_max_body_size or PHP upload_max_filesize/post_max_size is below the file size. See step 6.

Clone this wiki locally