What you are about to run
Rallly lets you suggest a few dates, send one link, and see when everybody is free. Self-hosting keeps the polls, names and addresses on your machine. The official rallly-selfhosted setup bundles the app, PostgreSQL for your data, Garage for uploads such as profile pictures, and Traefik for HTTPS. You will leave Traefik off and use Caddy, so you pick the moment the site goes live. This is the stack Rallly ships from v4.10 on; older hand-edited installs move over with the project's upgrade guide first.
Its needs are small: 2 GB of memory or more, Docker with Compose v2, a domain name and somewhere to send mail from.
Is a whole server too much for a poll app?
For Rallly alone, the smallest machines are plenty: our compact cartridge servers are made for one job each, and every one has more than 2 GB. Planning a few team tools together, like task lists with Vikunja or time tracking with Kimai? An everyday single-processor server gives them room. Compare them on our dedicated servers page.
A console you open yourself (screen plus power button) helps when a firewall step goes wrong: rack servers and blades all include one, as do two cartridge models, m510 and m710x, while other cartridges stop at power control. And skip X-Gene ARM cartridges: they run nothing newer than Ubuntu 18.04. Ready servers are handed over within 30 minutes of approval; ready servers are listed on their own page.
Three things to have ready
- A name for the site, like polls.example.com, that you can point at the server later.
- An SMTP account, ideally with a transactional email service. Logging in to Rallly means typing a one-time code it emails you; its source (lib/auth.ts) creates every account that way and keeps each code alive for 15 minutes. Once signed in you can add a password (lib/auth.ts allows that only from a session), but the first login is always a code. No mail, no logins, yours included. The install guide steers you away from your own mail server and from everyday inboxes like Gmail, which throttle automated senders.
- The addresses of the people allowed to create accounts, or a whole domain written as *@example.com.
Install it, with nothing public yet
Everything in this guide runs as root: if you log in with a sudo account, type sudo -i first. Then paste the block below. It switches the firewall on, letting in SSH alone over IPv4 and IPv6, installs Docker from Ubuntu's own packages only if that worked, and stops unless the Docker Engine version is at least 28: only from that release is an app bound to 127.0.0.1 private to the machine (Docker issue moby#45610). If Docker passes, it fetches the official files to /opt/rallly.
Rather than the interactive ./rallly.sh setup, it writes .env directly (the README allows that), so nothing pauses mid-paste. The file is locked to root before any secret goes in, the secrets are written only if that worked, ok shows only while the file still reads mode 600, and an existing one is left alone, because new secrets over existing data would shut you out of your own polls. When nano opens, add your mail details 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
engine=$(docker version 2>/dev/null | awk '/^Server/{s=1} s && /Version:/{sub(/.*Version: */,""); sub(/[.].*/,""); print; exit}')
if [ "${engine:-0}" -lt 28 ]; then
echo "STOP: Docker ${engine:-not found} is below 28; nothing was set up"
elif [ -e /opt/rallly/.env ]; then
echo "STOP: a settings file already exists; keeping it untouched"
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=*@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: Docker $engine, settings locked to root in /opt/rallly"
fi
[ -e /opt/rallly/.env ] && nano /opt/rallly/.env
Now start it: cd /opt/rallly && ./rallly.sh start. The first start chooses PostgreSQL 18 and records that in .env. This has to answer private: [ "$(ss -Htln 'sport = :3000' | tr -s ' ' | cut -d' ' -f4 | sort -u)" = 127.0.0.1:3000 ] && echo private. Leave docker-compose.yml alone: the docs warn that updates overwrite edits there.
Make yourself the admin while only you can see it
Rallly does not crown whoever arrives first: its control panel guide gives the Make me an admin button only to the account in INITIAL_ADMIN_EMAIL. Sign-up, though, is open by default, which is why ALLOWED_EMAILS goes in before the first start; the matching, which ignores capitals, lives in features/auth/utils.ts, and lib/auth.ts runs it on every sign-in and every code request.
- On your laptop, open a tunnel using the account you normally log in with:
ssh -L 3000:127.0.0.1:3000 -N you@polls-server. Keep 3000 on the laptop side too: lib/auth.ts trusts the origin http://localhost:3000 alone until you move to the public name. - Visit http://localhost:3000, type your INITIAL_ADMIN_EMAIL address, then the code from your inbox. No code?
/opt/rallly/rallly.sh logs webshows what your mail server replied. - Open http://localhost:3000/control-panel and press Make me an admin.
- Close the tunnel. Want no new accounts from here on? Add the line REGISTRATION_ENABLED=false to .env, then
/opt/rallly/rallly.sh restart; it outranks the control panel's own toggle.
Open it to your team
Order matters: the name, then the certificate, then Rallly's settings.
- Give polls.example.com an A record (the server's IPv4) and, when the server has IPv6, an AAAA record too. Carry on once
getent hosts polls.example.com, run on the server, prints its address. - Let Caddy fetch the certificate. Caddy runs straight on the host, so the firewall must admit web traffic over IPv4 and IPv6:
ufw allow 80,443/tcp. Then install it withapt-get install -y caddy(Ubuntu 24.04 has it) and write its config:printf 'polls.example.com {\n reverse_proxy 127.0.0.1:3000 {\n header_up X-Real-IP {http.request.remote.host}\n }\n}\n' | tee /etc/caddy/Caddyfile > /dev/null && systemctl reload caddy && systemctl is-active caddy. The X-Real-IP line matters: Rallly paces each visitor by that header, and its docs ask your proxy to send it. - Give Rallly its public name, restarting only if both edits stuck:
cd /opt/rallly && sed -i '/^NEXT_PUBLIC_BASE_URL=/d;s/^DOMAIN=localhost:3000$/DOMAIN=polls.example.com/' .env && grep -qx DOMAIN=polls.example.com .env && [ -z "$(grep NEXT_PUBLIC .env)" ] && ./rallly.sh restart
Your team now signs in at https://polls.example.com, and Caddy is the only way in, because Docker publishes the app on 127.0.0.1 alone. Published without an address, the app would listen on every address the machine has, 0.0.0.0 and [::]; over IPv4 Docker's rules would let traffic past ufw, and over IPv6 too if IPv6 support inside Docker itself had been turned on.
Keep a copy of everything
Rallly's own ./rallly.sh backup saves just the database; its management guide warns that uploads are stored separately, in garage-data, which needs a backup of its own. This nightly script keeps the database dump, .env (its secrets must match the data) and the Garage volumes. Rallly's guide names only garage-data; also taking garage-meta, the volume config/garage.toml uses for Garage's metadata, and stopping Garage while the copy runs are our own precautions, not Rallly's instructions. The scripts assume Compose named the volumes after the /opt/rallly folder (rallly_garage-data, rallly_garage-meta); each script halts if either is missing (docker volume ls shows them).
install -d -m 700 /var/backups/rallly-nightly /var/log/rallly-nightly
install -m 700 /dev/null /usr/local/sbin/rallly-nightly.sh
cat > /usr/local/sbin/rallly-nightly.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
out=/var/backups/rallly-nightly/$(date +%F-%H%M)
mkdir "$out"
docker compose exec -T db pg_dump -U postgres rallly | gzip > "$out/db.sql.gz"
gzip -t "$out/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 > "$out/uploads.tar.gz"
tar -tzf "$out/uploads.tar.gz" > /dev/null
cp .env "$out/env"
find /var/backups/rallly-nightly -mindepth 1 -maxdepth 1 -mtime +14 -exec rm -rf {} +
echo "ok $out"
EOF
printf '%s\n' '15 2 * * * root umask 077; bash /usr/local/sbin/rallly-nightly.sh >> /var/log/rallly-nightly/run.log 2>&1' > /etc/cron.d/rallly-nightly
Thanks to pipefail, a broken dump cannot leave a healthy-looking file; files are checked, Garage always restarts, and two-week-old copies go. Do a first run by hand (bash /usr/local/sbin/rallly-nightly.sh) and look for ok. You manage the server; we keep the hardware and the network running. A copy that lives on this disk only guards against your own slips, so pull the folder onto another computer on a timer with rsync -a, never --delete, and tidy old copies there.
If you ever need to roll back
This script puts back any nightly copy. It checks the copy, saves and checks today's state, and only then replaces the database (as Rallly's management guide does) and refills Garage. Regret it? Run it again on the folder it just saved. Like everything here, run it as root (from a sudo login, sudo -i before anything else). Open nano /usr/local/sbin/rallly-rollback.sh, paste the lines in, save, and hand it a folder such as /var/backups/rallly-nightly/2026-10-24-0215.
# /usr/local/sbin/rallly-rollback.sh; as root: bash it with COPY_DIR
set -euo pipefail
umask 077
from=$(realpath "${1:?which copy? give its folder}")
cd /opt/rallly
export COMPOSE_PROFILES=bundled-db,bundled-storage
export COMPOSE_FILE=docker-compose.yml:docker-compose.external-proxy.yml
gzip -t "$from/db.sql.gz"
tar -tzf "$from/uploads.tar.gz" > /dev/null
docker volume inspect rallly_garage-data rallly_garage-meta > /dev/null
docker compose stop web garage
undo=/var/backups/rallly-nightly/undo-$(date +%F-%H%M)
install -d -m 700 /var/backups/rallly-nightly && mkdir "$undo"
docker compose exec -T db pg_dump -U postgres rallly | gzip > "$undo/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 > "$undo/uploads.tar.gz"
gzip -t "$undo/db.sql.gz"
tar -tzf "$undo/uploads.tar.gz" > /dev/null
echo "today's state is saved and checked in $undo"
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 < "$from/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 "$from":/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 "back to $from; changed your mind? run this again with $undo"
Moving to a fresh server? Paste the install block there first (it starts nothing; leave nano without saving), then replace the settings it made with your saved copy (here /root/saved.env): install -m 600 /root/saved.env /opt/rallly/.env. Do it before Rallly starts even once. On first start the database container reads its password from .env and the app logs in with it, so a new file swapped for the old one later leaves them disagreeing. Create the folder with install -dm700 /var/backups/rallly-nightly, copy your backup folder into it, start Rallly once, run the rollback script on that folder, then the three opening-up steps.
Staying current, and what the licence asks of you
Update right after a fresh copy: run the nightly script, then /opt/rallly/rallly.sh update. It refreshes the setup files through git, fetches new images and recreates the containers, following the lukevella/rallly:4 tag; RALLLY_IMAGE in .env holds you on one release. The database major never changes on its own; when needed, /opt/rallly/rallly.sh upgrade-db copies the data into a fresh PostgreSQL 18 volume, keeps the old one so you can go back, and asks first.
Rallly is open source: the repository's LICENSE file holds the AGPL-3.0 text, while the README's own line says "or any later version". Our own general reading of it, and not legal advice: using it unchanged for your team adds no duty; handing it on to others, altered or as-is, means handing over the source on the same terms; and if you change it and others use your changed version over a network, you must offer them that changed source. Alongside that, the licensing page calls personal use free and asks setups with several users to buy a licence key, sold in tiers by registered users: Personal covers one, the next tiers 5 and 50, a top tier beyond. A key never expires for Rallly 4.x. Guests who only vote are not counted. Passing your tier switches nothing off (in the page's words, an honor system); Rallly puts up a reminder when registered users outnumber your seats, a single seat with no key. Each tier also limits Space members, and on Personal you cannot invite anyone.
Questions people ask
- Will the people I invite need to sign up before they can vote?
- No. Anyone with the poll link can answer as a guest; the licensing FAQ calls them guest voters and leaves them out of the user count. Accounts are for the people who create and run polls, and your ALLOWED_EMAILS list decides who those are.
- Do I have to pay to self-host Rallly?
- Not for one person: the project calls personal use free. With several registered users it asks you to buy a licence key, a one-off purchase that covers Rallly 4.x, in tiers by user count, and it trusts you rather than locking anything if you go over.
- Is anything missing compared with the hosted version?
- The Rallly API: per the self-hosting introduction, it runs on the project's hosted service alone, and billing and metered quotas are compiled out of the self-hosted image.