Install on a VPS (Ubuntu, no panel)

This guide installs Threadline on a plain Ubuntu server with no control panel and no Docker for the app: nginx, PHP-FPM, MySQL, Redis, Qdrant, a queue worker kept running by Supervisor, and one cron job. It takes about 30 minutes and every command is shown. You need to be comfortable typing commands over SSH, but you do not need to know how any of these programs work.

Prefer clicking to typing? Use Install: VPS (aaPanel). Prefer one command and Docker? Use Install: VPS (Docker).

What you need

Ubuntu 22.04 ships PHP 8.1, which is too old. Before step 2 run add-apt-repository -y ppa:ondrej/php && apt-get update to get PHP 8.3. Debian 12 needs the same kind of extra repository. This guide was tried on Ubuntu 24.04.

Step 1: Connect and open the firewall

Connect as root and allow SSH and web traffic:

ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

SSH (port 22) is allowed first so you do not lock yourself out. ufw enable warns that it may disrupt SSH; type y, your connection stays open. If your hosting provider has its own firewall, open ports 22, 80 and 443 there too.

Step 2: Install the software

export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y nginx php8.3-fpm php8.3-cli php8.3-mysql php8.3-mbstring \
  php8.3-xml php8.3-curl php8.3-gd php8.3-bcmath php8.3-zip php8.3-intl \
  mysql-server redis-server supervisor docker.io unzip curl

Check the versions that matter:

php -v | head -1
redis-cli ping

The first line must say PHP 8.3 or newer and the second must say PONG. Redis only listens on the server itself by default. Leave it that way.

Step 3: Create the site's user and folder

The website runs as its own user, so it owns only its own files. The same user runs the queue worker and the cron job.

useradd -m -s /bin/bash threadline
mkdir -p /var/www/threadline
chown threadline:threadline /var/www/threadline

Step 4: Create the database

Choose your own long password and use it in place of CHOOSE_A_LONG_PASSWORD. Keep it for step 11.

mysql -e "CREATE DATABASE threadline CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'threadline'@'localhost' IDENTIFIED BY 'CHOOSE_A_LONG_PASSWORD';
GRANT ALL ON threadline.* TO 'threadline'@'localhost';
FLUSH PRIVILEGES;"

On Ubuntu the root user can run mysql without a password. Do not use MySQL's own root account for Threadline.

Step 5: Run PHP as the site's user

Give the site its own PHP-FPM pool, running as threadline, then retire the default pool:

cat > /etc/php/8.3/fpm/pool.d/threadline.conf <<'EOF'
[threadline]
user = threadline
group = threadline
listen = /run/php/threadline.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
EOF
mv /etc/php/8.3/fpm/pool.d/www.conf /etc/php/8.3/fpm/pool.d/www.conf.disabled
systemctl restart php8.3-fpm

listen.owner = www-data lets nginx talk to the socket. Check that the pool is running as the right user:

ps -eo user:20,cmd | grep "php-fpm: pool" | grep -v grep

It should list threadline, not www-data.

Step 6: Set up nginx

cat > /etc/nginx/sites-available/threadline <<'EOF'
server {
    listen 80;
    server_name desk.example.com;
    root /var/www/threadline/public;
    index index.php;
    client_max_body_size 64M;

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

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/threadline.sock;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
EOF
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/threadline /etc/nginx/sites-enabled/threadline
nginx -t && systemctl reload nginx

nginx -t must say the test is successful before it reloads. The root line is the important one: it points at the public folder, so the rest of the files cannot be reached from the internet.

Step 7: Upload Threadline

  1. Copy the Threadline ZIP to the server, as you downloaded it. On your computer:
    scp threadline.zip root@YOUR_SERVER_IP:/tmp/
  2. On the server, extract it as the site's user:
    sudo -u threadline unzip -q /tmp/threadline.zip -d /var/www/threadline
    ls /var/www/threadline/artisan
    The second command must print the file's name. If it says there is no such file, the files went into a sub-folder: move them up one level.

Step 8: Start Qdrant

Qdrant is the vector database. Run it with Docker, for this one service only. It listens on the server itself and starts again after a reboot:

docker run -d --name qdrant --restart unless-stopped \
  -p 127.0.0.1:6333:6333 \
  -v qdrant_data:/qdrant/storage \
  qdrant/qdrant:v1.20.0
curl http://127.0.0.1:6333/readyz

The last command should answer all shards are ready (give it a few seconds). Do not open port 6333 to the internet.

Step 9: Turn on HTTPS

Do this now, before the installer, so the address you enter there is final. The domain's A record must already point at the server.

apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d desk.example.com

It asks three questions: an email address for expiry notices, Y to accept the Let's Encrypt terms, and whether to share your email with the EFF (either answer is fine). It then says Congratulations! You have successfully enabled HTTPS. Certbot edits the nginx site, sends plain http:// visits to https://, and sets up automatic renewal for you.

Step 10: Set up .env for a VPS

  1. Open your domain in a browser once. Threadline gives its .env file its own secret key and shows the installer. Leave that page open.
  2. Edit the file as the site's user:
    sudo -u threadline nano /var/www/threadline/.env
    Change these four lines, then save (Ctrl+O, Enter, Ctrl+X):
    SERVER=vps
    SESSION_DRIVER=redis
    QUEUE_CONNECTION=redis
    CACHE_STORE=redis

The Redis and Qdrant lines in the same file (REDIS_HOST=127.0.0.1, REDIS_PORT=6379, REDIS_CLIENT=predis and QDRANT_URL=http://127.0.0.1:6333) are already correct; leave them.

All three of QUEUE_CONNECTION, CACHE_STORE and SESSION_DRIVER must say redis. The example file sets them to database, and a value in .env wins over what SERVER=vps would choose. Do not fill in the database lines by hand; the installer asks for them.

Step 11: Run the installer

Reload your domain. The installer has five steps and is the same one as on shared hosting, described in Install: Shared Hosting.

  1. Requirements. Every row should be green. A red row names what is missing.
  2. App. The final address of your site, for example https://desk.example.com, and your timezone.
  3. Database. Host 127.0.0.1, port 3306, database threadline, user threadline and the password from step 4. Click Test connection, then Continue.
  4. Owner. Your own name, email and password.
  5. Install. It builds the database. It does not create any sample accounts or plans.

Use an empty database. The installer drops any tables already in it.

When the installer has finished, make .env readable by the site's user only. It holds the database password and the app key, and by default any account on the server can read it:

chmod 600 /var/www/threadline/.env
ls -l /var/www/threadline/.env

The second command should start with -rw------- and name threadline twice. Threadline can still change the file itself, because that user owns it.

Step 12: Start the queue worker

On a VPS a worker must always be running, or no message is sent. Supervisor keeps it alive and starts it again after a reboot.

cat > /etc/supervisor/conf.d/threadline-worker.conf <<'EOF'
[program:threadline-worker]
command=/usr/bin/php /var/www/threadline/artisan queue:work redis --queue=default,mail,webhooks --sleep=3 --tries=3 --max-time=3600
directory=/var/www/threadline
user=threadline
autostart=true
autorestart=true
stopasgroup=true
redirect_stderr=true
stdout_logfile=/var/www/threadline/storage/logs/worker.log
stopwaitsecs=3600
EOF
supervisorctl reread
supervisorctl update
supervisorctl status

The status must say RUNNING.

Step 13: Add the cron job

The scheduler sends reminders, runs campaigns and cleans up. It needs one cron job, running every minute as the site's user:

crontab -u threadline -e

If it asks which editor to use, choose nano. Add this line at the end, then save (Ctrl+O, Enter, Ctrl+X):

* * * * * cd /var/www/threadline && /usr/bin/php artisan schedule:run >> /dev/null 2>&1

Check it was saved with crontab -u threadline -l. See also Cron Setup.

Step 14: Check that everything works

Sign in as the Owner and open System → Cron & Health (details).

Reboot the server once to be sure (reboot): nginx, PHP, MySQL, Redis, Docker with Qdrant and the Supervisor worker all start again by themselves, and the site is back within about half a minute. Then connect an AI provider (AI Provider Setup) and set up email (Mail & Email Templates).

Optional: send public pages as finished HTML

Search engines read your public pages (home, pricing, about and the others) best when the server sends them already built. With SERVER=vps Threadline does that as soon as a small Node.js program runs next to it. Without it, pages are built in the visitor's browser as before, and nothing else changes. Screens behind the sign-in are always built in the browser.

apt-get install -y nodejs
node --version

The version must be 18 or newer. Then let Supervisor keep it running:

cat > /etc/supervisor/conf.d/threadline-ssr.conf <<'EOF'
[program:threadline-ssr]
command=/usr/bin/node /var/www/threadline/bootstrap/ssr/app.js
directory=/var/www/threadline
user=threadline
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/www/threadline/storage/logs/ssr.log
EOF
supervisorctl reread
supervisorctl update
supervisorctl status

Both programs must say RUNNING. It uses about 100 MB of memory. To check it, open your home page in a private window and view the page source: the page's text is in it, not only an empty <div id="app">. An update restarts it on the new version by itself.

Updating

Because the files are on the server's disk, you can update from the browser: System → Maintenance → App Update, upload the new ZIP and click Install. It backs up the database first, replaces the files, applies database changes and tells the worker to restart. Supervisor starts it again by itself.

Or over SSH, as the site's user (not as root):

cd /var/www/threadline
sudo -u threadline php artisan threadline:update /tmp/threadline-v1.2.0.zip

Copy the ZIP to the server first, for example with scp threadline-v1.2.0.zip root@YOUR_SERVER_IP:/tmp/. The command shows the installed and the new version and asks Continue?; type yes. It backs up the database, replaces the files with a progress bar, runs the database changes and ends with Updated. The site is back online. Supervisor restarts the worker on the new version by itself. You can then delete the ZIP from /tmp.

Full details, including what the backup contains, are in Updating Threadline.

If something goes wrong

What you see Likely cause and fix
502 Bad Gateway nginx cannot reach PHP. Check systemctl status php8.3-fpm and that the socket path in step 6 is /run/php/threadline.sock.
The home page works, but every other page is a 404 The try_files rule is missing from the nginx site (step 6).
The browser shows nginx's welcome page The default site is still enabled. Run rm /etc/nginx/sites-enabled/default and reload nginx.
Installer: storage/ or .env is writable is red The files are not owned by threadline. Run chown -R threadline:threadline /var/www/threadline and reload.
Installer: a PHP extension is Missing Install it, for example apt-get install php8.3-gd, then systemctl restart php8.3-fpm.
The scheduler never runs Check crontab -u threadline -l: the line must be there, with the right folder. Run it by hand to see the error: sudo -u threadline sh -c 'cd /var/www/threadline && php artisan schedule:run'
Pages load, but no message is ever sent The worker is not running. Run supervisorctl status; if it is not RUNNING, read /var/www/threadline/storage/logs/worker.log and check that QUEUE_CONNECTION is redis (step 10).
Knowledge-base answers are empty after you add documents Qdrant is not reachable. Run curl http://127.0.0.1:6333/readyz and check docker ps (step 8).

More in Troubleshooting & FAQ.