Skip to content

Deploying with TLS

TLS is not decoration here. Browsers expose the camera and microphone only in a secure context, so calls simply do not work over http:// on a remote host, and push notifications need one too. Plan for it before you invite anyone.

For a machine with nothing else on ports 80 and 443.

Terminal window
cp docker/env.example .env # set SLICK_DOMAIN
docker compose -f docker/compose.yml --env-file .env up -d

Caddy obtains and renews a Let’s Encrypt certificate automatically. Before starting, point an A record at the machine and open 80 and 443. Port 80 is how certificates are issued and renewed, not merely a redirect, so closing it breaks renewal sixty days later.

APP_URL derives from SLICK_DOMAIN, so invite links point at the public address. Only Caddy publishes ports; the app is reachable through it alone.

Forge provisions and manages nginx and the certificate, so Slick runs behind it rather than bringing Caddy along.

Pick Web server rather than App server when creating it: the app-server type also installs MySQL and PHP, and Slick uses neither — its database is SQLite inside the container.

Forge does not install Docker, so that is the one thing to add by hand:

Terminal window
ssh forge@your-server 'curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker forge'

Create the site for the domain in Forge and let it issue a certificate, then:

Terminal window
./docker/deploy.sh --behind-proxy forge@your-server

That runs the app on 127.0.0.1:3000, which is deliberate: it is unreachable from the internet even with the firewall open, and only nginx can get to it.

Finally, replace the site’s location / block with the contents of docker/forge-nginx.conf in Forge’s nginx editor. The Upgrade and Connection headers there are the part that matters — without them nginx refuses the WebSocket handshake and the app loads but never receives a message.

For calls, open 3478 and 5349 on both TCP and UDP in Forge’s firewall, and set TURN_CERT_DIR in .env to where the certificate lives, usually /etc/nginx/ssl. Without a certificate the relay still runs on 3478, which is what carries a call through symmetric NAT; only turns:// is lost.

The image build runs a full install and compile, which wants more memory than a small VPS has. A workstation can build it instead and ship the result, which also solves the other half of the problem: an Apple Silicon Mac produces an arm64 image, and a rented box is almost always amd64.

Terminal window
cp docker/env.example docker/.env # set SLICK_DOMAIN, at least
./docker/deploy.sh root@your-server

That cross-builds for linux/amd64, streams the image over SSH, copies the compose files and your .env to /opt/slick, and starts the stack. Run it again to deploy again.

compose.prebuilt.yml is what makes the server use the shipped image rather than trying to build one, and it stops Compose looking in a registry for a tag that only exists on your machine.

Before the first run, two things have to be true, and neither is something the script can do for you: the domain’s A record must already point at the server, because Caddy proves control of it to get a certificate, and port 80 must be reachable, because that is how the certificate is issued and renewed rather than merely a redirect to 443.

A host that already terminates TLS, a Laravel Forge box for example, has something on 80 and 443 already. Do not run Caddy alongside it.

Terminal window
docker compose -f docker/compose.behind-proxy.yml --env-file .env up -d

This publishes to 127.0.0.1:3000 only, so the app is unreachable from the internet even with the firewall open. Traffic has to come through the proxy.

docker/forge-nginx.conf is a working location block. The WebSocket upgrade headers in it are load bearing: without them the page loads, looks fine, and no message ever arrives.

Slick works behind Cloudflare, with two settings that matter:

  • WebSockets must be enabled in Network settings. They are on by default on current plans.
  • SSL mode should be Full (strict), with a real certificate on the origin. Flexible mode terminates TLS at Cloudflare and speaks plain HTTP to your server, which puts the origin back in the insecure-context trap.

Note that call media does not pass through Cloudflare. It is peer to peer, or it goes through your own relay.

There is a third thing to know, and it is easy to miss. If Web Analytics is on for the zone, Cloudflare rewrites the HTML of every proxied hostname on the way out to add its tracking script. That includes your Slick. Nothing warns you, and the setting is zone-wide, so turning it on for a marketing site turns it on for the workspace too. Slick’s own policy (below) refuses the script, so you get a console error rather than a tracker. If you would rather not see the error, turn it off under Analytics and Logs, Web Analytics.

The server sends one, and you should leave it alone.

Messages are decrypted in your browser, which means the plaintext lives in this origin. Any script that runs in this origin stands beside it. The policy is what says which scripts may run: your own, and nothing else. That is what makes the paragraph above a console error instead of a third party’s code sitting inside an encrypted workspace.

Two consequences for operators:

  • Do not strip or replace it at the proxy. If your proxy adds a policy of its own, browsers enforce both, and the result is the stricter of the two. That is safe. Replacing ours is not.
  • APP_URL should be set to the address people actually use. The policy names your WebSocket origin explicitly, because Safari does not treat 'self' as covering wss: and the macOS and iPhone apps are Safari underneath. With APP_URL unset the server infers it from the request, which works, but the configured value is the one that cannot be spoofed by a forged Host header.
  • Check https://your.domain/api/health.
  • Open a call with someone on a different network. If they join and hear nothing, you need TURN.
  • Turn on push in Preferences and confirm a notification arrives with the tab closed.
  • Set up backups before you have data worth losing.