Use case · Scheduling polls

Rallly self hosted on a dedicated server: the official stack, private until the admin is set

A self-hosted Rallly is the lukevella/rallly-selfhosted Docker Compose stack: the app beside a bundled Postgres database and Garage object storage, configured by one .env file and run with the rallly.sh script, plus an outgoing mail account, because every account starts with a code sent by email. This page builds it on one dedicated server running Ubuntu 24.04, keeps it on 127.0.0.1 until the admin role is claimed and the allow-list is in force, then puts Caddy in front. Where we depart from the official instructions, the text says so.

See the machines

What the stack runs

Rallly (three Ls) is a meeting-poll app. Since v4.10 its self-hosted form is a managed Compose stack: docker-compose.yml, a documented .env.example and rallly.sh. An older hand-edited install is moved onto it with the project's migration guide before anything here applies. The installation page asks for 2 GB of RAM, Docker 19.03+ with Compose v2, a domain resolving to the server and an SMTP server; it gives no CPU or storage figure.

PieceRuns asHolds
Rallly, image lukevella/rallly:4service web on 127.0.0.1:3000 (external-proxy mode)no state; sessions are kept in Postgres
PostgreSQL, 18 on fresh installsservice db, not publishedvolume db-data: accounts, polls, votes
Garage, S3-compatible storeservice garage, not publishedgarage-data and garage-meta: uploads such as avatars
Traefikskipped when PROXY_MODE=externalnothing
Settings and secretsread by Compose and the app/opt/rallly/.env

Compose derives NEXT_PUBLIC_BASE_URL (https:// plus DOMAIN) and DATABASE_URL (from the generated POSTGRES_PASSWORD); per the configuration page you set them only when running the image without the stack or with an outside database. SECRET_PASSWORD (32+ characters, encrypts sessions) and SUPPORT_EMAIL are yours.

Picking the machine

Against a 2 GB floor, the smallest class in the dedicated server range is enough: a Moonshot cartridge, which the range names for one service per box. Take a single-socket rack server when the box will also carry Vikunja or Kimai. Both are whole machines with root.

Console scope: racked single-, dual- and quad-socket servers have an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), as do the m510 and m710x cartridges; other cartridges get power control only. Not suitable: X-Gene ARM cartridges, offered with Ubuntu 18.04 at the newest.

Delivery is about 30 minutes from approval for a racked machine (ready servers) and 4 to 24 hours for a build to order. One IPv4 is included; IPv6 addresses are free at checkout.

Why outgoing mail comes first

There is no password sign-up. In lib/auth.ts an account is only ever created through a one-time code mailed to its address, valid 15 minutes (the README still says magic link); a password can be added later from a signed-in session, and resets go out by mail. No SMTP, no logins, the owner's included, which matches the configuration page.

The installation page wants a transactional provider, not a self-run mail server or a consumer inbox such as Gmail, which rate-limits automated senders.

Installing the stack on loopback

Every command on this page runs as root; from a sudo account, start with sudo -i. On Ubuntu 24.04, one block does host and config. ufw starts first, admitting SSH on both address families, and the package installs run only if that chain succeeded. Docker and Compose v2 come from Ubuntu's archive. The rest runs only on Docker Engine 28 or newer: Engine 28 made 127.0.0.1-only publishing truly host-local (moby#45610), which this setup relies on. The stack is cloned to /opt/rallly, the installer's default.

We write .env directly instead of the interactive ./rallly.sh setup (the README permits both), so no prompt eats a pasted line. Values follow the README's local mode; secrets use rallly.sh's hex lengths. The file is created at mode 600 and written only if that succeeded, ok prints only while the mode still reads 600, and the block refuses to run when .env exists: fresh secrets over live data lock you out. nano opens last for the SMTP lines and real addresses.

sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH && ufw --force enable &&
  apt-get update && apt-get install -y docker.io docker-compose-v2 git openssl
major=$(docker version 2>/dev/null | awk '/^Server/{s=1} s && /Version:/{sub(/.*Version: */,""); sub(/[.].*/,""); print; exit}')
if [ "${major:-0}" -lt 28 ]; then
  echo "STOP: Docker Engine ${major:-missing} is older than 28, nothing set up"
elif [ -e /opt/rallly/.env ]; then
  echo "STOP: /opt/rallly/.env exists, refusing to generate new secrets"
elif { [ -d /opt/rallly ] || git clone -q https://github.com/lukevella/rallly-selfhosted.git /opt/rallly; } && cd /opt/rallly; then
  install -m 600 /dev/null .env && cat >> .env <<EOF
DOMAIN=localhost:3000
PROXY_MODE=external
WEB_PORT=127.0.0.1:3000
NEXT_PUBLIC_BASE_URL=http://localhost:3000
SECRET_PASSWORD=$(openssl rand -hex 32)
SUPPORT_EMAIL=you@example.com
INITIAL_ADMIN_EMAIL=you@example.com
ALLOWED_EMAILS=you@example.com,*@example.com
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PWD=
POSTGRES_PASSWORD=$(openssl rand -hex 24)
S3_ACCESS_KEY_ID=$(openssl rand -hex 16)
S3_SECRET_ACCESS_KEY=$(openssl rand -hex 32)
GARAGE_RPC_SECRET=$(openssl rand -hex 32)
EOF
  [ "$(stat -c %a .env)" = 600 ] && echo "ok: Engine $major, .env is mode 600 in /opt/rallly"
fi
[ -e /opt/rallly/.env ] && nano /opt/rallly/.env

Then cd /opt/rallly && ./rallly.sh start. rallly.sh pins PostgreSQL 18 into .env, leaves Traefik out and publishes the app via the external-proxy override; this must print ok: ss -ltn | grep -q '127.0.0.1:3000 ' && ! ss -ltn | grep -Eq '(0\.0\.0\.0|\*|\[::\]):3000 ' && echo ok. No stack file is edited, since the management page warns that updates overwrite local edits.

Admin role and allow-list, before exposure

The admin role is not first-come: the control panel guide reserves it for the account matching INITIAL_ADMIN_EMAIL. Registration, however, is open by default. ALLOWED_EMAILS shuts it: comma-separated addresses, * wildcards, compared case-insensitively before any code goes out (features/auth/utils.ts). So the list exists from the first start, and the claim happens while only SSH reaches the app.

  1. From your workstation, logging in as yourself: ssh -N -L 3000:127.0.0.1:3000 you@server.example. The local side must be 3000 too: lib/auth.ts lists only http://localhost:3000 in trustedOrigins until the switch below.
  2. Browse to http://localhost:3000, enter the INITIAL_ADMIN_EMAIL address, type the mailed code. No mail means SMTP is wrong; since v4.16 the web log records the mail server's reply (/opt/rallly/rallly.sh logs web).
  3. At http://localhost:3000/control-panel, press Make me an admin.
  4. To freeze the account list, set REGISTRATION_ENABLED=false in .env and run /opt/rallly/rallly.sh restart; the variable overrides the control panel's registration switch.

Going public: DNS, then the certificate, then the config

  1. DNS. rallly.example.com gets an A record, and an AAAA one when the server has IPv6. Proceed once getent hosts rallly.example.com on the box returns its address.
  2. Certificate. Caddy on the host is an ordinary process, so ufw blocks it on IPv4 and IPv6 until ufw allow 80,443/tcp. Ubuntu 24.04 carries the package (apt-get install -y caddy). Site config: printf '%s\n' 'rallly.example.com {' ' reverse_proxy 127.0.0.1:3000 {' ' header_up X-Real-IP {remote_host}' ' }' '}' > /etc/caddy/Caddyfile && caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy. Rallly's rate limits read the visitor address from X-Real-IP, which the configuration page asks your proxy to set.
  3. Config. Point Rallly at the real name; the restart runs only if both edits applied: cd /opt/rallly && sed -i -e 's/^DOMAIN=.*/DOMAIN=rallly.example.com/' -e '/^NEXT_PUBLIC_BASE_URL=/d' .env && ! grep -q '^NEXT_PUBLIC_BASE_URL=' .env && grep -q '^DOMAIN=rallly.example.com$' .env && ./rallly.sh restart

The app port stays on loopback. Docker publishes an address-less mapping on 0.0.0.0 and [::] alike; on IPv4 such traffic bypasses ufw, and on IPv6 likewise provided the Docker daemon itself has IPv6 networking switched on.

Backups that hold all of it

./rallly.sh backup dumps only the database; the management page says uploads sit in the garage-data volume and need their own backup. That page names only garage-data. Two additions are our own reasoning, not the docs': config/garage.toml puts Garage's metadata in a second volume, garage-meta, so we archive it too, and we stop Garage during the copy so the two volumes agree. .env goes alongside, since its secrets must match the restored data. Volume names carry the project prefix, rallly for /opt/rallly; both scripts stop at once if docker volume inspect cannot find rallly_garage-data and rallly_garage-meta, rather than archiving empty new volumes.

install -d -m 700 /var/backups/rallly /var/log/rallly-backup
install -m 700 /dev/null /usr/local/sbin/rallly-backup.sh
cat > /usr/local/sbin/rallly-backup.sh <<'EOF'
set -euo pipefail
umask 077
cd /opt/rallly
export COMPOSE_PROFILES=bundled-db,bundled-storage
export COMPOSE_FILE=docker-compose.yml:docker-compose.external-proxy.yml
d=/var/backups/rallly/$(date +%F-%H%M)
mkdir "$d"
docker compose exec -T db pg_dump -U postgres rallly | gzip > "$d/db.sql.gz"
gzip -t "$d/db.sql.gz"
docker volume inspect rallly_garage-data rallly_garage-meta > /dev/null
docker compose stop garage
trap 'docker compose start garage' EXIT
docker run --rm -v rallly_garage-data:/v/data:ro -v rallly_garage-meta:/v/meta:ro \
  alpine:3 tar -C /v -czf - data meta > "$d/uploads.tar.gz"
tar -tzf "$d/uploads.tar.gz" > /dev/null
cp .env "$d/env"
find /var/backups/rallly -mindepth 1 -maxdepth 1 -mtime +14 -exec rm -rf {} +
echo "ok $d"
EOF
printf '%s\n' '30 3 * * * root umask 077; bash /usr/local/sbin/rallly-backup.sh >> /var/log/rallly-backup/run.log 2>&1' > /etc/cron.d/rallly-backup

pipefail stops a failed dump passing as an archive; files are read back; the trap restarts Garage; sets past 14 days are pruned. Test once with bash /usr/local/sbin/rallly-backup.sh; the last line must start with ok. Then pull /var/backups/rallly to another machine with plain rsync -a, never --delete (it would mirror a wiped source), and prune there.

Restoring over the running instance

The restore follows the management page (drop and recreate the rallly database, stream the dump in with ON_ERROR_STOP=1) and adds two guards: it tests the chosen set, then saves and tests the current state before deleting anything, so running it again on that copy undoes it. As root (sudo -i first from a sudo account), create the file with install -m 700 /dev/null /usr/local/sbin/rallly-restore.sh, paste the lines in, and pass a set such as /var/backups/rallly/2026-10-24-0330.

# /usr/local/sbin/rallly-restore.sh; as root: bash /usr/local/sbin/rallly-restore.sh SET_DIR
set -euo pipefail
umask 077
src=$(realpath "${1:?name the backup set directory}")
cd /opt/rallly
export COMPOSE_PROFILES=bundled-db,bundled-storage
export COMPOSE_FILE=docker-compose.yml:docker-compose.external-proxy.yml
gzip -t "$src/db.sql.gz"
tar -tzf "$src/uploads.tar.gz" > /dev/null
docker volume inspect rallly_garage-data rallly_garage-meta > /dev/null
docker compose stop web garage
keep=/var/backups/rallly/pre-restore-$(date +%F-%H%M)
install -d -m 700 /var/backups/rallly && mkdir "$keep"
docker compose exec -T db pg_dump -U postgres rallly | gzip > "$keep/db.sql.gz"
docker run --rm -v rallly_garage-data:/v/data:ro -v rallly_garage-meta:/v/meta:ro \
  alpine:3 tar -C /v -czf - data meta > "$keep/uploads.tar.gz"
gzip -t "$keep/db.sql.gz"
tar -tzf "$keep/uploads.tar.gz" > /dev/null
echo "current state saved and verified in $keep"
docker compose exec -T db psql -U postgres -d postgres -c 'DROP DATABASE IF EXISTS rallly;'
docker compose exec -T db psql -U postgres -d postgres -c 'CREATE DATABASE rallly;'
gunzip < "$src/db.sql.gz" | docker compose exec -T db psql -q -v ON_ERROR_STOP=1 -U postgres rallly
docker run --rm -v rallly_garage-data:/v/data -v rallly_garage-meta:/v/meta -v "$src":/b:ro \
  alpine:3 sh -c 'find /v/data /v/meta -mindepth 1 -delete && tar -C /v -xzf /b/uploads.tar.gz'
./rallly.sh start
echo "restored from $src; to undo, run again with $keep"

On a replacement server, run the first block (nothing starts in it; quit nano unsaved), then overwrite its throwaway file with the saved one, install -m 600 /root/env /opt/rallly/.env, before Rallly ever starts there. The Postgres container takes its password from .env when it initialises an empty volume and the app connects with the same value; a fresh .env replaced later leaves the two disagreeing. On the new box create the directory (install -d -m 700 /var/backups/rallly) and copy the backup set into it, start once, run the restore script on it, and repeat the going-public steps.

Updates and the licence

./rallly.sh update fast-forwards the stack files with git, pulls images and recreates containers; it follows lukevella/rallly:4, the newest 4.x, and RALLLY_IMAGE in .env pins another tag. Chain it after the backup script with &&. The PostgreSQL major never moves on update; /opt/rallly/rallly.sh upgrade-db takes an older install to 18 through dump and restore into a new volume, keeps the old one for rollback and asks for confirmation, so run it alone.

Licence: LICENSE in lukevella/rallly carries the AGPL-3.0 text, and the README adds "or any later version". Our general reading, not legal advice: running it unmodified for your own team adds no obligation; passing copies on, modified or not, means passing the source on under the same terms; and a modified build that people use over a network obliges you to offer them its source. Separately, the licensing page calls Rallly free for personal use and asks multi-user instances to buy a licence key, in tiers by registered users: one on Personal, then up to 5, up to 50 and beyond. Keys are perpetual for v4.x. Guest voters are not counted. Over the limit nothing stops (an honor system, the page says); license-limit-warning.tsx shows a reminder once registered users exceed the licensed seats, one without a key. Spaces have per-tier seat caps; Personal cannot invite members.

Questions

Can Rallly run without an SMTP account?

Not for a team. Accounts are created only through a one-time code sent to the address, so without outgoing mail nobody registers or signs in. For real use the installation page recommends a transactional provider.

Do participants need an account to answer a poll?

No. The licensing FAQ counts people who take part in polls without registering as guest voters, outside the user limit. ALLOWED_EMAILS restricts registration and sign-in, not who can open a poll link, so keep the link private if the poll is.

Is self-hosted Rallly free?

The code is AGPL-3.0. The project's licensing page calls one registered user (Personal) free and asks larger instances to buy a perpetual v4.x key, with tiers sized by user count. Going over the tier does not disable anything; a reminder appears. Current tiers are on that page.

Does a self-hosted instance have the API or the open-poll limit?

Neither. Per the self-hosting introduction, the Rallly API is served only by the hosted service, and self-hosted accounts can have any number of polls collecting votes at once. Spaces collaboration is always on, the instance itself being the organisation's boundary.

Run Rallly on a machine of your own

Pick a cartridge or a single-socket server, install Ubuntu 24.04 from the control panel and follow the steps above.

See the machines

Related on Server Room

Other use cases