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 uploads volume when you use local file storage

db

PostgreSQL 17 with the vector (pgvector) extension

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 caddy_data volume

File storage

Local disk (the uploads volume) or an S3-compatible bucket you choose later in System, Config

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 (docker compose version prints v2), curl, tar, openssl, jq, and dig (package dnsutils). The installer stops if Docker, Compose or openssl is missing.

Domain

A name such as app.example.com that you can point at the server with an A record (and AAAA if you use IPv6).

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.

Email

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

1.

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.

2.

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.

3.

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.

4.

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 ufw does 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 to 127.0.0.1. Also open 80 and 443 in your hosting provider's own firewall or security group: ufw does 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.

5.

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.

1.

Find the server's public IPv4 address (run on the server).

curl -4fsS https://ifconfig.me
2.

At your DNS provider, create an A record for app.example.com pointing at it (for example 203.0.113.10).

3.

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.

1.

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.

2.

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.

3.

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_KEY makes saved connector credentials unreadable. Losing AUTH_SECRET signs everybody out. Never paste the file into a chat or a ticket.

Step 5. Sign in as the administrator

1.

Open https://app.example.com. You land on the sign-in page.

2.

Enter admin in Email and the generated password in Password, then press Sign in.

3.

Open the profile button at the top right, choose User profile & settings, then Change password, and set your own password.

4.

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

Diagnostics (see )

Connectors

Connector packages and OAuth apps

Step 6. Make the first settings

Do these before you invite anyone. Each is explained in the .

1.

Config, General, Public App URL: confirm it is https://app.example.com. Every emailed link is built from it.

2.

Config, Email: choose SMTP or Resend, set the From address, press Test Connection, then Save.

3.

Config, AI: add at least one AI provider and assign it the roles your projects use. Without one, AI features do nothing.

4.

Config, Storage & Logs: keep Local filesystem, or switch to S3-compatible now, before users upload files.

5.

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.

1.

Install with plain HTTP, giving the final address.

./install.sh --url https://app.example.com --port 3000
2.

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.

3.

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
a.

The Upgrade and Connection headers carry the WebSocket connections used for live editing. Without them, editors disconnect.

b.

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.

c.

proxy_buffering off lets event streams reach the browser as they happen.

d.

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.

e.

X-Forwarded-Host and X-Forwarded-Proto tell the app which public address and scheme it is served on.

4.

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.

1.

Back up. From the current release directory:

docker compose exec -T db pg_dump -U codedocs -Fc codedocs > ~/codedocs-$(date +%F).dump
cp .env ~/env-backup-$(date +%F)

Copy both files off the server. The full backup plan, including uploads and certificates, is in .

2.

Download, verify and unpack the new release exactly as in Step 3, next to the old directory.

3.

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.

4.

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

docker compose ps

app, db, valkey (and caddy) are Up; app is healthy

App health

curl -fsS http://127.0.0.1:3000/api/health

"status":"ok", db and valkey ok

Migrations applied

docker compose exec -T db psql -U codedocs -d codedocs -c 'SELECT count(*) FROM drizzle.__drizzle_migrations'

A number greater than 0; it grows with each release

Extensions

docker compose exec -T db psql -U codedocs -d codedocs -c '\dx'

vector and pg_trgm are listed

Valkey

docker compose exec -T valkey valkey-cli ping

PONG

App port not public

ss -ltn | grep ':3000' (with --domain)

Listens on 127.0.0.1:3000 only

HTTPS

curl -fsSI https://app.example.com/api/health

200

HTTP redirect

curl -sI http://app.example.com

Redirects to https://

Sign-in

Open the site, sign in as admin

You reach the Projects page and see the gear button

Email

Config, Email, Test Connection

A success message

Survives a reboot

sudo reboot, then docker compose ps

All services return without your help (they use restart: unless-stopped; Docker itself must start at boot, which systemctl enable docker ensures)

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.

1.

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.

2.

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
3.

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.

4.

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.

5.

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.

Next steps

•

: every environment variable and every System, Config setting.

•

•

, ,

•

: the list your project owners see.