Install the server
Install the server
Who this is for: the administrator who installs and runs a Juxtaboard server on a Linux host.
What it covers: prerequisites, a step-by-step install with Docker, HTTPS, the first sign-in, updating, and a verification checklist. The settings you make afterwards are in the ; faults are in .
Before you begin you need: a Linux server you control with root or sudo, a domain name you can point at it, and the release download address, which is published on the self-hosting page.
Self-hosted only. This whole guide applies when your organisation runs the installation itself. Nothing here applies to a project on a hosted instance.
What you are installing
One application container, plus the services it needs. The release bundle starts all of them with Docker Compose.
Part | What it is | What it holds |
|---|---|---|
app | The Juxtaboard web application (Node.js). Listens on port 3000 inside the container. | Nothing of its own, except uploaded files on the |
db | PostgreSQL 17 with the | All project data, accounts, settings |
valkey | Valkey 8 (a Redis-compatible server), with append-only persistence | Background job queues, live-editing state, the key that protects saved connector credentials |
caddy (optional) | A reverse proxy that gets and renews a free HTTPS certificate | The certificate, on the |
File storage | Local disk (the | Uploaded images, files and attachments |
The bundle's technical name is codedocs: you will see it in image, volume and database names. Nothing else is needed: no separate worker, queue broker or search server.
The app needs PostgreSQL and Valkey to start. If Valkey is unreachable at start, the app stops with an error instead of running in a broken state.
Prerequisites
Requirement | Detail |
|---|---|
Host | A 64-bit Linux server with systemd. The release image is built for x86_64 (amd64); no arm64 build is published. Ubuntu 22.04 and 24.04 and Debian 12 are expected to work, but no distribution is certified. |
Size | Floor: 2 vCPU, 4 GB RAM, 30 GB free disk. Comfortable: 4 vCPU, 8 GB RAM. This is guidance, not a load test. The download is about 600 MB and unpacks into several GB inside Docker, and the database and uploads grow on top. Keep at least 10 GB free before you download. |
Software | Docker Engine with the Compose plugin ( |
Domain | A name such as |
Ports | 80 and 443 free on the host, and open in the firewall of your hosting provider. 443/udp carries HTTP/3; browsers fall back if it is blocked. |
Outgoing access | HTTPS from the server to your AI provider, email service and certificate authority, if you use them. |
An SMTP account or a Resend account. Without outgoing email, new users cannot confirm their address and nobody receives invitations or sign-in links. |
You do not need Node.js, Bun or a build toolchain on the host: the image contains the built application.
Step 1. Prepare the host
Check the host.
uname -m && head -3 /etc/os-release && nproc && free -g && df -h /
Check: uname -m prints x86_64, and the size is at or above the floor. On an aarch64 host, stop: there is no image for it.
Install Docker Engine and the Compose plugin if docker compose version fails. Docker's convenience script runs as root and downloads packages, so read it before you run it.
curl -fsSL https://get.docker.com | sh
sudo systemctl enable --now docker
Add your user to the docker group (then log in again), or run every Docker command below with sudo.
sudo usermod -aG docker "$USER"
Check: docker compose version prints v2.x, and sudo docker run --rm hello-world succeeds.
Install the helper tools.
sudo apt-get update && sudo apt-get install -y curl jq openssl ca-certificates dnsutils
Check: command -v curl jq openssl dig prints four paths.
Open the firewall. Allow SSH first so you do not lock yourself out. If you use ufw:
sudo ufw allow OpenSSH && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw allow 443/udp && sudo ufw status
Careful: Docker publishes ports by writing firewall rules itself, so
ufwdoes not protect a port that a container publishes. Only 80 and 443 should be reachable from outside. The database and Valkey are not published at all. With--domain(Step 4) the app's own port 3000 is bound to127.0.0.1. Also open 80 and 443 in your hosting provider's own firewall or security group:ufwdoes not control it.
Check: sudo ss -ltnp 'sport = :80 or sport = :443' prints nothing. If another web server holds those ports, use your own reverse proxy instead of Caddy.
Recommended: add swap, so a memory spike slows the host down instead of ending in the out-of-memory killer, which may pick the database. Skip this if the host already has swap (swapon --show lists it).
sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw,nofail 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/20-swap.conf && sudo sysctl --system
A low swappiness (10 instead of the default 60) keeps PostgreSQL's cache in memory and uses swap only as an emergency buffer.
Check: swapon --show lists /swapfile, and sysctl vm.swappiness prints 10.
Step 2. Point the domain at the server
Create the DNS record before you install; the certificate request in Step 4 fails without it.
Find the server's public IPv4 address (run on the server).
curl -4fsS https://ifconfig.me
At your DNS provider, create an A record for app.example.com pointing at it (for example 203.0.113.10).
If a DNS proxy such as Cloudflare's orange cloud sits in front of the name, switch the record to DNS-only until the certificate is issued.
Check: both commands print only your server's address. A stale AAAA record that points elsewhere makes certificate validation fail, so remove it.
dig +short A app.example.com
dig +short AAAA app.example.com
Step 3. Download and verify the release
The self-hosting page gives the address where releases are published. It serves a small latest.json file that names the newest version, its size and its checksum. Never hard-code a version.
Fetch the pointer.
mkdir -p ~/juxtaboard && cd ~/juxtaboard
export RELEASE_URL="https://downloads.example.com" # replace with the release address from the self-hosting page
curl -fsS "$RELEASE_URL/latest.json" -o latest.json && jq . latest.json
Check: valid JSON with version, url, sha256 and size, and free disk of at least three times size.
Download with resume, so a dropped SSH session does not restart the download, then verify the checksum.
curl -fL -C - --retry 5 -o release.tar "$(jq -r .url latest.json)"
echo "$(jq -r .sha256 latest.json) release.tar" | sha256sum -c -
Check: release.tar: OK. Anything else: delete the file and download once more. The checksum proves the file arrived intact. It does not prove who published it, because it comes from the same place as the file.
Unpack.
VERSION=$(jq -r .version latest.json) && tar xf release.tar && cd "codedocs-$VERSION" && ls
Check: the listing shows image.tar.gz, docker-compose.yml, .env.example, install.sh and VERSION. Stay in this directory for the rest of the guide: every docker compose command runs here.
Step 4. Run the installer
install.sh loads the image into Docker, writes a .env file with freshly generated secrets, starts the stack and waits until the app reports healthy. It writes .env only if none exists, so running it again never changes your keys.
With automatic HTTPS (recommended):
./install.sh --domain app.example.com
--domain turns on the bundled Caddy proxy, which requests and renews a free certificate. It sets PUBLIC_APP_URL to https://app.example.com, binds the app's own port to 127.0.0.1, and requires ports 80 and 443. If the name does not resolve from the host yet, the script warns you: treat that as a failed precondition and fix DNS first. Repeated failed certificate requests are throttled by the certificate authority for an hour or more.
Without a domain, for a first look only:
./install.sh --url http://203.0.113.10:3000
Careful: plain HTTP sends passwords unencrypted. Use it to try the product, never for real users.
Other flags: --port N changes the host port for plain HTTP (default 3000); --url URL sets the address people open, which is used for cookies, emailed links and sign-in callbacks.
The first start applies the database migrations and can take a few minutes. The installer prints the admin login at the end.
Check: the output ends with running at https://app.example.com, then login: admin and a generated password. Note the password; you can read it again later.
grep '^ADMIN_INITIAL_PASSWORD=' .env
If it says the app did not become healthy within 6 minutes, see .
Confirm the stack is healthy
docker compose ps
curl -fsS http://127.0.0.1:3000/api/health
Check: app, db and valkey are Up (healthy), plus caddy if you used --domain. The health reply starts with "status":"ok" and its checks list db and valkey as ok. With --domain, also confirm that HTTPS works from outside:
curl -fsSI https://app.example.com/api/health | head -1
It prints HTTP/2 200. Caddy needs up to a minute to obtain the certificate on the first start. If it does not appear, read docker compose logs caddy.
Back up .env now
.env holds the keys of the installation. Copy it off the server to your secrets manager today, for example from your own computer:
scp user@203.0.113.10:~/juxtaboard/codedocs-*/.env ./juxtaboard-env-backup
Careful: losing
CONNECTOR_ENCRYPTION_KEYmakes saved connector credentials unreadable. LosingAUTH_SECRETsigns everybody out. Never paste the file into a chat or a ticket.
Step 5. Sign in as the administrator
Open https://app.example.com. You land on the sign-in page.
Enter admin in Email and the generated password in Password, then press Sign in.
Open the profile button at the top right, choose User profile & settings, then Change password, and set your own password.
Leave ADMIN_INITIAL_PASSWORD in .env. Do not blank it. If the admin account is ever repaired at start, an empty value would reset it to a well-known default password.
Check: sign out and sign in again with the new password.
The admin account holds all four system rights. The administration pages are behind the gear button at the top of the Projects list (its label is System administration):
Menu item | What it is for |
|---|---|
Config | Installation-wide settings (email, AI, storage, sign-in, payments) |
Jobs, Health | Background work and whether the schedulers are running |
Users, Billing | Accounts, blocking, and the plan of each billing account |
Traces, Client errors, Index advisor | |
Connectors | Connector packages and OAuth apps |
Step 6. Make the first settings
Do these before you invite anyone. Each is explained in the .
Config, General, Public App URL: confirm it is https://app.example.com. Every emailed link is built from it.
Config, Email: choose SMTP or Resend, set the From address, press Test Connection, then Save.
Config, AI: add at least one AI provider and assign it the roles your projects use. Without one, AI features do nothing.
Config, Storage & Logs: keep Local filesystem, or switch to S3-compatible now, before users upload files.
Billing: once your own account appears in the list (it is created the first time it is needed, for example when you create a project), confirm it is on the Self-hosted plan (no plan limits, backups on). New accounts get that plan automatically through DEFAULT_PLAN_CODE=self_hosted; if an account is on another plan, change it here.
Check: the test in step 2 succeeds, and you can create a project from the Projects page. From here your project owners take over: see the , and send them the list in so they know what to ask you for.
Use your own reverse proxy instead of Caddy
Choose this when ports 80 and 443 are already used by an existing web server, or when you must use your own proxy and certificates.
Install with plain HTTP, giving the final address.
./install.sh --url https://app.example.com --port 3000
Bind the app's port to the loopback interface, so only the proxy reaches it. In .env, set:
APP_BIND=127.0.0.1
docker compose up -d
The port is briefly open on all interfaces during the first install, so keep 3000 closed in your provider's firewall.
Create the nginx site. The map goes in the http context, for example in a file under /etc/nginx/conf.d/.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
client_max_body_size 50M;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
server_tokens off;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
}
}
Why each part matters:
sudo nginx -t && sudo systemctl reload nginx
The Upgrade and Connection headers carry the WebSocket connections used for live editing. Without them, editors disconnect.
proxy_read_timeout and proxy_send_timeout of 86400 s keep long-lived WebSocket and event-stream connections open; the default 60 s cuts them.
proxy_buffering off lets event streams reach the browser as they happen.
client_max_body_size is the largest upload nginx accepts. 50 MB is the value in the project's reference configuration. The default Embed File Max Size (MB) setting is 100, so raise this to at least the size you want to allow.
X-Forwarded-Host and X-Forwarded-Proto tell the app which public address and scheme it is served on.
Get a certificate. Do this only after DNS points at the server.
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx --agree-tos --email you@example.com -d app.example.com
certbot adds the HTTPS listener and a redirect from port 80, and renews the certificate on its own timer.
Check: curl -fsSI https://app.example.com/api/health prints HTTP/2 200 (or HTTP/1.1 200), and live editing works in two browser windows.
Careful: the project sub-domains of the form
<project-slug>.<your host>, custom domains and storefront hosts need their own wildcard DNS and certificates. The bundled Caddy does not set them up. Skip them unless you publish sites or portals on their own host names.
Use an existing PostgreSQL or Valkey
The bundle is built for its own db and valkey services. Use your own only if you must, for example a managed database. This path is not exercised by the installer.
Requirements:
PostgreSQL with the vector and pg_trgm extensions available. The app role must be allowed to run CREATE EXTENSION, or a superuser must create them first in the target database (CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pg_trgm;). The first migration needs them, and the migration step stops with a clear message when it cannot enable them.
The bundled database runs PostgreSQL 17. Use 16 or newer; versions older than that are not checked.
A database owned by the app role, reachable over TCP with password authentication.
Valkey (or a Redis-compatible server) with persistence turned on. The bundle runs Valkey 8; other servers are not checked.
Then, in docker-compose.yml, remove the db and valkey services and their depends_on entries, and set the connection strings of the app service:
DATABASE_URL=postgresql://appuser:password@db.internal.example:5432/juxtaboard
VALKEY_URL=redis://:password@valkey.internal.example:6379
A new bundle replaces docker-compose.yml at each update, so keep a copy of your edits and re-apply them.
Update to a newer release
Updating loads the new image, keeps every key and applies the database migrations at start. The migrations run automatically; the app takes a short break while it restarts.
Careful: there is no downgrade after the migrations have run. The only way back is to restore the database from the backup you take in step 1 and start the previous image.
Download, verify and unpack the new release exactly as in Step 3, next to the old directory.
Copy the old .env into the new directory, then run the installer. Add --domain again if you use Caddy.
cp ../codedocs-OLD_VERSION/.env . && ./install.sh --domain app.example.com
Check: the output ends with running at ..., and curl -fsS http://127.0.0.1:3000/api/health shows "status":"ok" and the new build in its version block.
Remove release.tar and the old directory when you are satisfied. The data lives in Docker volumes, not in these directories.
If you edited docker-compose.yml, re-apply your edits before step 3.
Verification checklist
After a fresh install, every row should pass.
Check | Command | Expected |
|---|---|---|
Stack up |
|
|
App health |
|
|
Migrations applied |
| A number greater than 0; it grows with each release |
Extensions |
|
|
Valkey |
|
|
App port not public |
| Listens on |
HTTPS |
|
|
HTTP redirect |
| Redirects to |
Sign-in | Open the site, sign in as | You reach the Projects page and see the gear button |
Config, Email, Test Connection | A success message | |
Survives a reboot |
| All services return without your help (they use |
Run from source without Docker (advanced)
The release bundle is the supported path. Use this only if you build the application yourself; it needs source access and the sibling whiteboard package that the release bundle does not contain. The steps follow what the image does.
Requirements: Node.js 24.x (the project declares 24.x; the image uses 24.9.0), Bun 1.3.14 for installing and building, a C toolchain and libvips for the image library, PostgreSQL and Valkey as above. A production build can use a very large heap (the build script allows 12 GB), so give the build host enough memory or swap.
Create the database, the role and the extensions. The extensions must exist before the migrations, because the first migration uses them and the app role cannot create the vector extension.
sudo -u postgres psql -v ON_ERROR_STOP=1 -c "CREATE ROLE juxtaboard LOGIN PASSWORD 'CHOOSE_A_LONG_PASSWORD'" -c "CREATE DATABASE juxtaboard OWNER juxtaboard"
sudo -u postgres psql -d juxtaboard -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS vector" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm"
The app connects over TCP, so pg_hba.conf needs a host juxtaboard juxtaboard 127.0.0.1/32 scram-sha-256 line. Check: PGPASSWORD=... psql -h 127.0.0.1 -U juxtaboard -d juxtaboard -c '\dx' lists both extensions.
Write an environment file outside the checkout, mode 600, for example /etc/juxtaboard/app.env. The variables are described in the . The minimum is:
DATABASE_URL=postgresql://juxtaboard:CHOOSE_A_LONG_PASSWORD@127.0.0.1:5432/juxtaboard
VALKEY_URL=redis://127.0.0.1:6379
AUTH_SECRET=<output of: openssl rand -base64 48>
CONNECTOR_ENCRYPTION_KEY=<output of: openssl rand -base64 32>
PUBLIC_APP_URL=https://app.example.com
ADMIN_INITIAL_PASSWORD=<a long random password>
DEFAULT_PLAN_CODE=self_hosted
NODE_ENV=production
PORT=3000
HOSTNAME=127.0.0.1
The project declares its whiteboard package as file:../wboard/packages/whiteboard-core, so that package must sit next to the checkout and be built first. Then install, migrate and build. Valkey must be running during the build, because several route modules connect to it when Next.js collects page data.
(cd ../wboard && bun install && bun run build)
bun install && bun --env-file=/etc/juxtaboard/app.env run db:migrate && bun --env-file=/etc/juxtaboard/app.env run build
Check: db:migrate ends without an error, and the build ends with a route table.
Run the server under Node, not Bun. The stall detector (the watchdog) needs a native module that Bun cannot load; under Bun it switches itself off at every start and logs [Watchdog] Disabled. Set NEXT_MANUAL_SIG_HANDLE=true, so the app can save live documents and finish running jobs when it is told to stop. A minimal systemd unit:
[Unit]
Description=Juxtaboard
After=network-online.target postgresql.service valkey-server.service
Wants=network-online.target
[Service]
User=juxtaboard
WorkingDirectory=/srv/juxtaboard/app
EnvironmentFile=/etc/juxtaboard/app.env
Environment=NEXT_MANUAL_SIG_HANDLE=true
ExecStart=/usr/bin/node node_modules/next/dist/bin/next start
Restart=always
TimeoutStopSec=20
[Install]
WantedBy=multi-user.target
Run it from the directory where you built, or from a copy that holds the build output (.next), node_modules, worker-dist, public, drizzle and package.json. Missing worker-dist makes every background job fail with MODULE_NOT_FOUND while the pages keep working. The stop timeout must exceed the app's 12-second shutdown budget.
Check: curl -fsS http://127.0.0.1:3000/api/health returns "status":"ok", and ls worker-dist lists the four *.worker.cjs files.
Put nginx and a certificate in front, as in Use your own reverse proxy. To update, repeat step 3 and restart the service.
Use db:migrate, never db:setup. db:setup runs drizzle-kit push, which does not use the migration history and tries to drop objects that the extensions own.