Skip to content

Install on a VM

The VM install puts every piece of Tidal PTC on the server as a regular system package: Nginx and PHP-FPM serve the app, PostgreSQL and Redis run locally, systemd keeps Reverb (websockets) and the queue worker running, and cron runs the scheduler. One script, install-vm.sh, does all of it. The manual steps below are the same install done by hand.

District IT staff who want to self-host without containers. If you’re happy with Docker, the Docker install has fewer moving parts.

  • Check the requirements: a Debian 12/13 or Ubuntu 24.04/26.04 server, a DNS A record pointing at it, and ports 80 and 443 open.
  • Point your domain’s DNS at the server before running the script, so the certificate request succeeds.

Need a server? A DigitalOcean Droplet works well:

DigitalOcean Referral Badge

  1. Run the install script with your domain and email:

    Terminal window
    curl -fsSL https://raw.githubusercontent.com/archboard/tidal-ptc/main/install-vm.sh \
    | sudo bash -s -- --domain ptc.example.org --email [email protected]
  2. Wait for it to finish. It installs PHP 8.5, PostgreSQL 18, Redis, Nginx, Certbot, Node.js and Composer; clones the code into /var/www/tidal-ptc as a tidal user; creates the database; gets a Let’s Encrypt certificate; writes .env; builds and migrates the app; and sets up the systemd services and the scheduler cron entry. At the end it checks that https://<domain>/up returns 200.

  3. Visit https://ptc.example.org/install to finish the installation. See First run.

Option Description
--domain DOMAIN Public hostname (required; prompted for if omitted).
--email EMAIL Let’s Encrypt expiry notices (optional).
--version REF Git branch or tag to deploy (default: main).
--no-tls Skip Let’s Encrypt and serve plain HTTP, for example behind a load balancer that terminates TLS.
--dir PATH Install directory (default: /var/www/tidal-ptc).
-h, --help Show the options.

The script is safe to re-run: it keeps existing secrets and .env values, and pulls, rebuilds, migrates and restarts everything. Re-run it to update, after editing .env, or after fixing DNS if the certificate request failed.

Run the commands as a normal user with sudo. Replace ptc.example.org with your domain throughout.

Ubuntu 26.04 ships PHP 8.5 in universe. Older Debian and Ubuntu releases need Ondřej Surý’s repository.

Terminal window
sudo apt-get update
sudo apt-get install -y ca-certificates curl git unzip lsb-release
# Debian 12 / 13, Ubuntu 24.04 (skip on Ubuntu 26.04)
curl -sSL https://packages.sury.org/php/README.txt | sudo bash -x
sudo apt-get update
sudo apt-get install -y php8.5-fpm php8.5-cli php8.5-pgsql php8.5-redis php8.5-intl \
php8.5-zip php8.5-mbstring php8.5-xml php8.5-curl php8.5-bcmath
Terminal window
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer

Don’t use the distro composer package; it pulls in the distro’s older PHP.

Node.js is only needed to build the front-end assets.

Terminal window
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs

From the official PostgreSQL apt repository:

Terminal window
sudo apt-get install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get install -y postgresql-18

Create the database and its user. Keep the password for .env.

Terminal window
DB_PASSWORD=$(openssl rand -hex 24); echo "$DB_PASSWORD"
sudo -u postgres psql -c "CREATE ROLE tidal LOGIN PASSWORD '$DB_PASSWORD';"
sudo -u postgres psql -c "CREATE DATABASE tidal_ptc OWNER tidal;"

PostgreSQL only listens on localhost by default, which is what you want.

Terminal window
sudo apt-get install -y redis-server
sudo systemctl enable --now redis-server

The packaged config binds to 127.0.0.1 only, so no password is needed for a single-server install.

Terminal window
sudo apt-get install -y nginx certbot python3-certbot-nginx
sudo systemctl enable --now nginx

The app, PHP-FPM, the queue worker and Reverb all run as one dedicated tidal user, so files written by any of them (logs, cache) are writable by the others.

Terminal window
sudo useradd --create-home --shell /bin/bash tidal
sudo mkdir -p /var/www/tidal-ptc
sudo chown tidal:tidal /var/www/tidal-ptc
sudo -u tidal git clone https://github.com/archboard/tidal-ptc.git /var/www/tidal-ptc

main is the latest code. To run a release instead, run sudo -u tidal git -C /var/www/tidal-ptc checkout v1.2.3 (tags match the GitHub releases).

Terminal window
sudo -iu tidal
cd /var/www/tidal-ptc
cp .env.example .env
sed -i "s|^APP_KEY=.*|APP_KEY=base64:$(openssl rand -base64 32)|" .env

Edit .env and set the values listed in Configuration. Leave TRUSTED_PROXIES unset: Nginx talks to PHP-FPM directly over FastCGI, so the app already sees the real client IP and HTTPS.

Still as the tidal user in /var/www/tidal-ptc:

Terminal window
composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build
php artisan migrate --force
php artisan optimize
exit

php artisan optimize caches config and routes, so rerun it after every .env change.

Give the app its own pool running as tidal. Create /etc/php/8.5/fpm/pool.d/tidal-ptc.conf:

[tidal-ptc]
user = tidal
group = tidal
listen = /run/php/tidal-ptc.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500
Terminal window
sudo systemctl restart php8.5-fpm

Raise pm.max_children if you have the RAM (roughly 50 MB per child).

Create /etc/nginx/sites-available/tidal-ptc. Start with plain HTTP; Certbot adds TLS in the next step.

map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name ptc.example.org;
root /var/www/tidal-ptc/public;
index index.php;
charset utf-8;
# Reverb websockets
location ~ ^/apps?(/|$) {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
location ~ ^/index\.php(/|$) {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/tidal-ptc.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
}

Only index.php is executed; any other .php file under public/ is served as a 404.

Terminal window
sudo ln -s /etc/nginx/sites-available/tidal-ptc /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ptc.example.org --redirect

Certbot rewrites the server block to listen on 443 with the new certificate, adds the HTTP to HTTPS redirect, and installs a systemd timer that renews automatically. sudo certbot renew --dry-run confirms renewal works.

Create /etc/systemd/system/tidal-ptc-reverb.service:

[Unit]
Description=Tidal PTC Reverb websocket server
After=network.target redis-server.service
[Service]
User=tidal
Group=tidal
WorkingDirectory=/var/www/tidal-ptc
ExecStart=/usr/bin/php artisan reverb:start --host=127.0.0.1 --port=8080
Restart=always
RestartSec=3
LimitNOFILE=10000
[Install]
WantedBy=multi-user.target

And /etc/systemd/system/tidal-ptc-queue.service:

[Unit]
Description=Tidal PTC queue worker
After=network.target redis-server.service postgresql.service
[Service]
User=tidal
Group=tidal
WorkingDirectory=/var/www/tidal-ptc
ExecStart=/usr/bin/php artisan queue:work redis --queue=default,sis_sync --tries=3 --max-time=3600
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

--queue=default,sis_sync processes mail and notifications before PowerSchool syncs. --max-time=3600 makes the worker exit hourly to release memory; systemd starts a fresh one.

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now tidal-ptc-reverb tidal-ptc-queue

Reminders and activity-log pruning run from the Laravel scheduler. Add it to the tidal user’s crontab:

Terminal window
echo '* * * * * cd /var/www/tidal-ptc && php artisan schedule:run >> /dev/null 2>&1' | sudo crontab -u tidal -
  • https://ptc.example.org/up returns 200.
  • systemctl status tidal-ptc-reverb tidal-ptc-queue shows both active (running).
  • Open the browser dev tools on any page: there should be a websocket connection to wss://ptc.example.org/app/<REVERB_APP_KEY> with status 101. If it fails, see Real-time updates.
  • Errors land in /var/www/tidal-ptc/storage/logs/ and /var/log/nginx/error.log.

Then visit https://ptc.example.org/install to finish the installation.

Run app commands as the tidal user: sudo -iu tidal, then cd /var/www/tidal-ptc.

Task Command
Run Artisan sudo -u tidal php /var/www/tidal-ptc/artisan <command>
Tail app logs tail -f /var/www/tidal-ptc/storage/logs/laravel-*.log
Tail service logs journalctl -fu tidal-ptc-queue -u tidal-ptc-reverb

After changing .env, re-cache the config and restart the services:

Terminal window
sudo -u tidal php /var/www/tidal-ptc/artisan optimize
sudo systemctl reload php8.5-fpm && sudo systemctl restart tidal-ptc-queue tidal-ptc-reverb
Tidal PTC by Archboard