One-liner (fresh VPS)
/opt/slideless, generates
secrets into .env (mode 600), starts the stack, and configures UFW
(22/80/443). With a domain, finish by wiring the reverse proxy
(reverse-proxy.md) and setting TRUST_PROXY=true in
.env.
Without a domain
The app is published on127.0.0.1 only and the app port stays closed in
the firewall. Reach the dashboard through an SSH tunnel:
0.0.0.0 bind is
reachable from the internet no matter what the firewall says — the bind
address is the only control that actually holds. And the wizard request
carries the owner password and the setup token, so the server itself refuses
to complete setup over plaintext HTTP on a non-loopback origin
(403 insecure_transport).
To publish anyway on a trusted private network, pass --expose-port: it
binds 0.0.0.0, opens the port in ufw, and sets ALLOW_INSECURE_SETUP=true.
It needs to know the address this host is reached on, which it reads from
hostname -I; on a host where that prints nothing it stops and asks, so pass
HOST_IP=<address> alongside it rather than letting an empty value become the
instance’s PUBLIC_BASE_URL.
Manual (any machine with Docker)
setup.sh is idempotent: with an existing .env it just (re)starts.
.env carries the host publication settings, and update.sh / restore.sh
read them back — so a custom port survives an upgrade instead of reverting:
First boot
The dashboard shows the setup wizard: instance name + owner account. The wizard always requires a setup token — this stops a stranger racing you to own a freshly exposed instance.setup.sh and the one-liner installer
generate one into .env; a container started without SETUP_TOKEN (a
hand-written .env, a plain docker run) generates its own at first boot,
writes it to /data/setup-token inside the data volume and prints it in
the container log (docker compose logs app | grep setup). Setup is never
first-come-first-served. It runs exactly once; afterwards the endpoint
answers 410 Gone and the generated token file is removed.
Setup is also refused over plaintext HTTP on a non-loopback PUBLIC_BASE_URL
(403 insecure_transport) — the request carries the owner password and the
token. Use https, an SSH tunnel to the loopback bind, or the deliberate
ALLOW_INSECURE_SETUP=true opt-in.
Teammates join via invitations (Members → Invite). Every invitation yields a
copyable accept link — SMTP is never required. To also send invitation
emails, configure an email driver in .env
(env-reference.md).
What’s running
The app container is stateless by design — all state lives in Postgres and
the
/data volume. Migrations apply automatically at boot under an advisory
lock; set AUTO_MIGRATE=false to run them manually (the instance then
refuses readiness while behind).