Can I self-host Doodle?
YES, BUT · ONE WEEKEND— setup effort 3 of 4YES, BUT — it's called Rallly. It takes one prompt, a 2048 MB VPS, and about 210 minutes. That is $10.95 a month you stop paying Doodle — $131.40 a year on the Pro plan, 1 seat assumed.
Why people pay for Doodle
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Doodle sells the end of the reply-all thread. You post the times you could meet, everyone else ticks boxes on a page that needs no account, and the winning slot is obvious without anyone reading twelve emails. What the paid tiers add is mostly the removal of limits and irritations you meet on the free one: more than a single live poll, no advertising on the page your colleagues open, deadlines and reminders that chase people for you, and calendar and video-call links that stop the follow-up admin.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | One Group Poll, one Booking Page, one 1:1, basic Sign-up Sheets and one calendar connection. Participants see ads. |
| Prothe plan this page prices against | $10.95/mo per seat | Billed monthly. $83.40 a year on the annual plan, which the page presents as $6.95 a month. Doodle's own help centre lists this plan at $15 a month and $132 a year, so the figure a given visitor is shown depends on which price test they land in. Buys unlimited polls, booking pages and sign-up sheets, deadlines and reminders, Zoom, Teams and Webex links, and an ad-free page. |
| Team | $19.95/mo per seat | Per user per month billed monthly, minimum two seats and maximum 100. $8.95 per user per month on the annual plan, shown on the page as $214.80 a year for the two-seat default. Adds an admin console, roles, co-hosting, booking on someone else's behalf, activity reports and custom branding. |
| Enterprise | quote only | Quote only, from USD 15,000 and billed annually, for organisations of 50 or more. Adds SAML and OIDC single sign-on, MFA, branded booking pages and a 99.9% SLA. |
Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium
Replaced by Rallly
One project, named before the prompt, so you know what you are about to install.
Send a group a link, let them tick the times that work, and pick the winner, with no cap on how many people answer.
The closest thing to what Doodle actually sells: you propose several dates or times, you send one link, and anyone who opens it votes without making an account. The participant count that Doodle's free tier caps and its paid tiers unlock is a row in your own PostgreSQL here, and the ads that sit on a free Doodle page never existed. The honest catch is that sign-in is a code emailed to you, so you are signing up to keep an SMTP relay working, and upstream asks you to buy a licence key once more than one person has an account on the instance.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Ralllyour pickONE WEEKEND— setup effort 3 of 4
Send a group a link, let them tick the times that work, and pick the winner, with no cap on how many people answer.
The closest thing to what Doodle actually sells: you propose several dates or times, you send one link, and anyone who opens it votes without making an account. The participant count that Doodle's free tier caps and its paid tiers unlock is a row in your own PostgreSQL here, and the ads that sit on a free Doodle page never existed. The honest catch is that sign-in is a code emailed to you, so you are signing up to keep an SMTP relay working, and upstream asks you to buy a licence key once more than one person has an account on the instance.
- Cal.comONE WEEKEND— setup effort 3 of 4
Send someone a link, they pick a slot, the invite lands in both calendars, and none of it runs on a domain you are renting.
The other half of what Doodle does, and the right answer if your real problem is one-to-one bookings rather than polling a group. Cal.com publishes your availability and lets a single person pick a slot straight out of it, which is Doodle's Booking Page feature rather than its Group Poll. Rallly ranks first here because group time-polling is the thing Doodle is named for; if you have never made a group poll and only ever sent people a booking link, install this one instead.
The swap
You'd run
Rallly
ONE WEEKEND · ~210 min to running · 2048 MB RAM
Doodle Pro · 1 seat assumed · vendor list price · checked 2026-08-07 · source · confidence: medium
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~210 min3–24 hours, through the first backup
The prompt
Two paths to the same Rallly: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
333 lines · 14,993 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Rallly 4.12.1 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. Say this when you ask: the hostname becomes
`NEXT_PUBLIC_BASE_URL`, it is printed inside every invitation this instance mails, and changing
it later breaks links other people are already holding.
Rallly needs 2048 MB of RAM available and 5 GB free on /srv, upstream's own floor. Both images
publish amd64 and arm64. Measure four things:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 2048 MB or free disk is under 5 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop.
Settle one thing more, because step 3 stops dead without it. Rallly signs people in by mailing a
six-digit code, so this instance needs an SMTP relay. Upstream names Resend, Postmark, Mailgun and
Brevo, and says not to run your own mail server and not to point this at a Gmail or Proton
mailbox, because consumer inboxes rate-limit automated senders and eventually block the sign-in
mail. Tell the user to have a host, port, username and password from a transactional provider in
front of them.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/rallly /srv/rallly/backups
sudo install -d -m 700 /srv/rallly/postgres
ls -la /srv/rallly
```
Assert: `ls -la` shows `backups` owned by the login user and `postgres` at mode `700` owned by
root. The PostgreSQL image chowns its own data directory on first start, so leave that one alone.
Rallly keeps nothing else on disk: polls, options, votes, comments and accounts are all rows.
## 3. Secrets
Two secrets: the PostgreSQL password and the session key. Generate both on the server. Do not
print either, do not repeat them in your summary, and do not put them in any log line. Upstream
documents `openssl rand -hex 32` for `SECRET_PASSWORD` and rejects anything under 32 characters;
hex also keeps the connection string free of characters that would need escaping.
```bash
umask 077
cat > /srv/rallly/.env <<EOF
NEXT_PUBLIC_BASE_URL=https://<DOMAIN>
POSTGRES_PASSWORD=$(openssl rand -hex 32)
SECRET_PASSWORD=$(openssl rand -hex 32)
REGISTRATION_ENABLED=true
SUPPORT_EMAIL=CHANGE_ME
INITIAL_ADMIN_EMAIL=CHANGE_ME
SMTP_HOST=CHANGE_ME
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=CHANGE_ME
SMTP_PWD=CHANGE_ME
EOF
chmod 600 /srv/rallly/.env
umask 022
ls -l /srv/rallly/.env
```
Assert: the file exists with mode `-rw-------`. `SUPPORT_EMAIL` is validated as an email address
at start-up, so the container refuses to boot while it reads `CHANGE_ME`. `INITIAL_ADMIN_EMAIL`
is the one address allowed to claim the admin role in step 7, and it should be the user's own.
`REGISTRATION_ENABLED` is true only until step 7 closes it.
STOP: tell the user to open `nano /srv/rallly/.env`, replace every `CHANGE_ME` with the matching
value, correct `SMTP_PORT` if their relay is not 587 and set `SMTP_SECURE=true` if it is 465,
save, and confirm. Do not continue until they confirm, and never ask them to paste those values.
```bash
grep -c CHANGE_ME /srv/rallly/.env || true
```
Assert: that prints `0`. It counts lines, never values.
## 4. compose.yml
```bash
cat > /srv/rallly/compose.yml <<'EOF'
# Rallly · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://support.rallly.co/self-hosting/installation/docker
# configuration ...... https://support.rallly.co/self-hosting/configuration
# entrypoint ......... https://github.com/lukevella/rallly/blob/v4.12.1/scripts/docker-start.sh
# status endpoint .... https://github.com/lukevella/rallly/blob/v4.12.1/apps/web/src/app/api/status/route.ts
#
# Two services: Rallly and the PostgreSQL that holds every poll, vote, comment
# and account. Upstream's own stack adds a bundled Traefik and a Garage object
# store, and this file runs neither: Caddy on the host terminates TLS, and the
# S3 variables are optional, so leaving them unset costs avatar and logo
# uploads and nothing else. The container runs its own Prisma migrations before
# the server listens, which the health check's start period allows for. Digests
# read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: rallly-db
restart: unless-stopped
environment:
POSTGRES_DB: rallly
POSTGRES_USER: rallly
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/rallly/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rallly -d rallly"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
rallly:
image: lukevella/rallly:4.12.1@sha256:6049260ff6d3accd86730372a650b5e8063c373a09f253c45f7e4a8dc9202752
container_name: rallly
restart: unless-stopped
env_file: /srv/rallly/.env
environment:
# Built here rather than kept in .env so the password appears once.
DATABASE_URL: postgres://rallly:${POSTGRES_PASSWORD}@postgres:5432/rallly
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/status || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8153.
- "127.0.0.1:8153:3000"
depends_on:
postgres:
condition: service_healthy
EOF
cd /srv/rallly && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. No password sits in that file: compose reads
`${POSTGRES_PASSWORD}` out of /srv/rallly/.env to build the connection string. Upstream's stack
runs two containers this one does not, a Traefik and a Garage object store, and dropping Garage
costs avatar and logo uploads.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-rallly
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Rallly · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://support.rallly.co/self-hosting/installation/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also NEXT_PUBLIC_BASE_URL in .env, and the container reads it to build the
# poll links it mails out, so the two have to agree exactly.
<DOMAIN> {
# Every invitee opens a page served from here, and the sign-in code arrives
# by mail, so a downgrade attack has a real audience. Nothing here is meant
# to be embedded in another site, so framing stays same-origin.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8153 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8153
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-rallly, reload, and report what it objected to. Caddy asks for the
certificate on the first request and renews it itself, so there is nothing to schedule.
## 6. Firewall
Two ports open, both Caddy's. Idempotent, so on a Prompt Zero box they change nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8153 stays closed because compose binds it to 127.0.0.1, and 5432 because compose never
publishes it, so the database has no host port a rule could apply to. Assert: `ufw status verbose`
prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule for 8153 or 5432.
## 7. Start and verify
The container applies its own Prisma migrations before the server listens, so the first boot takes
a few minutes on a small box.
```bash
cd /srv/rallly
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/status
curl -sS https://<DOMAIN>/login | grep -c '>Log in to your account or create a new one</p>'
```
Assert all three, and print what you received for each. The loop ends printing `200`. The status
body is a small JSON object containing `"status":"ok"` and `"database":"connected"`. The grep
prints `1`. If any misses, stop, run `docker compose logs --tail 60 rallly` and
`docker compose logs --tail 20 postgres`, and name the likely cause: a database that never
reports healthy points at step 2; an `Invalid environment variables` line points at step 3, where
a `CHANGE_ME` left in `SUPPORT_EMAIL` or a short `SECRET_PASSWORD` stops the process before it
listens; a `502` while the loop still runs means migrations are still going. A running container
is not success.
The first screen at https://<DOMAIN>/login is headed `Welcome`, with
`Log in to your account or create a new one` under it and one box asking for an email address.
STOP: tell the user to open https://<DOMAIN>/login, enter the address they put in
`INITIAL_ADMIN_EMAIL`, type in the six-digit code Rallly mails to it, then open
https://<DOMAIN>/control-panel and press the button that makes them an admin.
Do not continue until they confirm both. That code arriving is the only proof the relay from
step 3 works; if nothing lands within two minutes, read step 10 before touching anything.
Once they confirm, close registration and prove it is closed:
```bash
sed -i 's/^REGISTRATION_ENABLED=true$/REGISTRATION_ENABLED=false/' /srv/rallly/.env
cd /srv/rallly
docker compose up -d --force-recreate rallly
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/login | grep -c '>Login to your account to continue</p>'
curl -sS https://<DOMAIN>/login | grep -c '>Log in to your account or create a new one</p>' || true
```
Assert: the loop reaches `200` again, the first grep prints `1` and the second prints `0`. That
pair is the security assert here. Registration left open on a public hostname is an account for
anyone who can receive mail, and voting on a poll never needed an account. Both asserts must pass
before you report success.
## 8. First backup and restore
Two artifacts. The database holds every poll, vote, comment and account. The config archive holds
what rebuilds the service around it.
```bash
cd /srv/rallly
docker compose exec -T postgres pg_dump -U rallly -d rallly | gzip > /srv/rallly/backups/rallly-db-$(date +%F).sql.gz
sudo tar -czf /srv/rallly/backups/rallly-config-$(date +%F).tar.gz -C /srv/rallly compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/rallly/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently. A backup on the same disk is not a backup, so run this
from the user's machine:
```bash
mkdir -p ~/backups/rallly
scp vps:/srv/rallly/backups/* ~/backups/rallly/
```
To restore: `docker compose down`, `sudo rm -rf /srv/rallly/postgres`, recreate that directory as
in step 2, untar the config archive into /srv/rallly so .env is back before anything starts,
`docker compose up -d postgres`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U rallly -d rallly`, then `docker compose up -d`. Tell the
user why the archive matters as much as the dump: `SECRET_PASSWORD` lives in .env, every session
is sealed with it, and a database restored beside a new key logs everybody out at once.
## 9. Updating later
New versions are listed at https://github.com/lukevella/rallly/releases. Take both backup
artifacts first, then edit the image line in /srv/rallly/compose.yml to the new tag and its
digest:
```bash
cd /srv/rallly
docker compose pull
docker compose up -d
docker compose logs --tail 40 rallly
```
Rallly migrates its own database on the way up, so watch that log until it settles, then re-run
the `/api/status` check from step 7 before calling the update done. Stay inside the 4.x line: the
licence upstream sells is perpetual for 4.x and a major bump is a separate decision.
## 10. What will probably go wrong
The sign-in code will not arrive, and nothing will look broken. I sat on the `Verify your email`
screen for ten minutes with a healthy container, a `200` from `/api/status` and an empty inbox,
and the Rallly log said nothing, because the app had handed the message to the relay and the
relay had refused it out of sight. Make that conversation visible: add `SMTP_DEBUG=true` to
/srv/rallly/.env, run `docker compose up -d --force-recreate rallly`, try the sign-in again, and
read `docker compose logs --tail 60 rallly`. Mine was rejecting the from-address because the
domain was not verified with the relay yet. Take `SMTP_DEBUG` out once mail lands: it prints the
whole SMTP exchange into the log.
## 11. Out of scope
- Do not configure single sign-on. OIDC, Google and Microsoft each need a client registered in
somebody else's console, and this install signs people in by email.
- Do not add upstream's bundled Traefik or Garage containers. Caddy already terminates TLS here,
and the object store buys avatar and logo uploads at the price of a third service to operate.
- Do not set `APP_NAME`, `LOGO_URL` or `HIDE_ATTRIBUTION`. Those need a purchased licence key,
and this install runs the free one.
- Do not install a mail server on this box. Upstream tells you to use a transactional provider,
and port 25 on a fresh VPS is a fight with no prize.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing Rallly 4.12.1 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the box.
Two things to settle before step 1. `<DOMAIN>` becomes `NEXT_PUBLIC_BASE_URL`, the address printed
inside every invitation this instance mails, so changing it later breaks links other people are
holding. And Rallly signs people in by mailing a six-digit code, with no other way in, so step 3
will ask you for the host, port, username and password of an SMTP relay. Upstream names Resend,
Postmark, Mailgun and Brevo, and says not to run your own mail server and not to use a Gmail or
Proton mailbox, because consumer inboxes rate-limit automated senders and eventually block the
sign-in mail. Sign up for one now if you have not.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `2048` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
and run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 2048 MB of RAM is
the other common stop: Rallly is a Next.js server beside a PostgreSQL, and 2 GB is upstream's own
floor rather than a number we picked. Add swap or resize the box; do not install and hope.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/rallly /srv/rallly/backups
sudo install -d -m 700 /srv/rallly/postgres
ls -la /srv/rallly
```
You should see: `backups` owned by you, and `postgres` at mode `drwx------` owned by root.
If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it refuse
to initialise.
## 3. Secrets
Two secrets, the PostgreSQL password and the session key, both generated here on the server and
both written straight into a file only you can read. Upstream documents `openssl rand -hex 32` for
`SECRET_PASSWORD` and rejects anything shorter than 32 characters.
```bash
umask 077
cat > /srv/rallly/.env <<EOF
NEXT_PUBLIC_BASE_URL=https://<DOMAIN>
POSTGRES_PASSWORD=$(openssl rand -hex 32)
SECRET_PASSWORD=$(openssl rand -hex 32)
REGISTRATION_ENABLED=true
SUPPORT_EMAIL=CHANGE_ME
INITIAL_ADMIN_EMAIL=CHANGE_ME
SMTP_HOST=CHANGE_ME
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=CHANGE_ME
SMTP_PWD=CHANGE_ME
EOF
chmod 600 /srv/rallly/.env
umask 022
ls -l /srv/rallly/.env
```
You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on the
first line with your real hostname before you paste.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines separately in different shells. Run `chmod 600 /srv/rallly/.env` and carry on. If
the file already existed from an earlier attempt, this block has now overwritten both secrets,
which is fine before the database exists and a problem afterwards: PostgreSQL keeps the password
it was created with, so a changed one on an existing volume produces an authentication failure in
the Rallly log rather than anything that mentions passwords.
Do not paste that file, either secret, or any output containing them into this chat window. The
agent path never sees those values, and this one hands them to a third party unless you decline.
Now edit the file: `nano /srv/rallly/.env`, and replace every `CHANGE_ME`. `SUPPORT_EMAIL` is the
address shown to people as your contact, and it is validated as an email at start-up, so the
container refuses to boot while it still reads `CHANGE_ME`. `INITIAL_ADMIN_EMAIL` is the one
address allowed to claim the admin role in step 7; make it your own. The four `SMTP_` lines are
your relay. Correct `SMTP_PORT` if it is not 587, and set `SMTP_SECURE=true` if your relay uses
465. Save, then:
```bash
grep -c CHANGE_ME /srv/rallly/.env
```
You should see: `0`.
If you do not: the number printed is how many lines still hold a placeholder, and every one of
them stops step 7. It counts lines, never values, so it is safe to paste back here.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/rallly/compose.yml <<'EOF'
# Rallly · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://support.rallly.co/self-hosting/installation/docker
# configuration ...... https://support.rallly.co/self-hosting/configuration
# entrypoint ......... https://github.com/lukevella/rallly/blob/v4.12.1/scripts/docker-start.sh
# status endpoint .... https://github.com/lukevella/rallly/blob/v4.12.1/apps/web/src/app/api/status/route.ts
#
# Two services: Rallly and the PostgreSQL that holds every poll, vote, comment
# and account. Upstream's own stack adds a bundled Traefik and a Garage object
# store, and this file runs neither: Caddy on the host terminates TLS, and the
# S3 variables are optional, so leaving them unset costs avatar and logo
# uploads and nothing else. The container runs its own Prisma migrations before
# the server listens, which the health check's start period allows for. Digests
# read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: rallly-db
restart: unless-stopped
environment:
POSTGRES_DB: rallly
POSTGRES_USER: rallly
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/rallly/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rallly -d rallly"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
rallly:
image: lukevella/rallly:4.12.1@sha256:6049260ff6d3accd86730372a650b5e8063c373a09f253c45f7e4a8dc9202752
container_name: rallly
restart: unless-stopped
env_file: /srv/rallly/.env
environment:
# Built here rather than kept in .env so the password appears once.
DATABASE_URL: postgres://rallly:${POSTGRES_PASSWORD}@postgres:5432/rallly
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/status || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8153.
- "127.0.0.1:8153:3000"
depends_on:
postgres:
condition: service_healthy
EOF
cd /srv/rallly && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/rallly/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/rallly/compose.yml` and paste again in one go. Upstream's own stack runs two more
containers, a Traefik and a Garage object store, and this file runs neither. Caddy is already on
the box, and the S3 variables are optional in the app's environment schema, so the whole cost of
dropping Garage is that avatar and logo uploads are unavailable.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-rallly
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Rallly · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://support.rallly.co/self-hosting/installation/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also NEXT_PUBLIC_BASE_URL in .env, and the container reads it to build the
# poll links it mails out, so the two have to agree exactly.
<DOMAIN> {
# Every invitee opens a page served from here, and the sign-in code arrives
# by mail, so a downgrade attack has a real audience. Nothing here is meant
# to be embedded in another site, so framing stays same-origin.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8153 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8153
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-rallly /etc/caddy/Caddyfile`, reload, and
paste again. The usual cause is a `<DOMAIN>` you forgot to replace, which Caddy reads as a
hostname made of angle brackets. Caddy asks for the certificate on the first request and renews it
itself, so there is nothing for you to schedule.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8153` or `5432`.
If you do not: delete anything for `8153` or `5432` with `sudo ufw delete allow 8153`. 8153 is
bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the database has no
host port a firewall rule could apply to. 80/tcp answers the ACME challenge and redirects to
HTTPS, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back before you go any further.
## 7. Start and verify
The container applies its own Prisma migrations before the server listens, so the first boot takes
a few minutes. The loop below waits it out.
```bash
cd /srv/rallly
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/status
curl -sS https://<DOMAIN>/login | grep -c '>Log in to your account or create a new one</p>'
```
You should see, in order: the loop reaching `200`, a small JSON object containing `"status":"ok"`
and `"database":"connected"`, then `1`.
If you do not: read `docker compose logs --tail 60 rallly` first. An
`Invalid environment variables` line means step 3 is incomplete, and the two candidates are a
`CHANGE_ME` still sitting in `SUPPORT_EMAIL` and a `SECRET_PASSWORD` shorter than 32 characters.
If the loop never leaves `502`, the container is still migrating, so give it another few minutes
before touching anything. If `docker compose logs --tail 20 postgres` shows a database that never
reports healthy, step 2 is the place to look. A container that is running is not the same thing as
an install that works.
The first screen at https://<DOMAIN>/login is headed `Welcome`, with
`Log in to your account or create a new one` under it and one box asking for an email address.
Open it now, type in the address you put in `INITIAL_ADMIN_EMAIL`, and wait for the six-digit code
Rallly mails to you. Enter the code, then open https://<DOMAIN>/control-panel and press the button
that makes you an admin. That code landing in your inbox is the only proof your relay works; if
nothing arrives within two minutes, read step 10 before you change anything.
Now close registration, because it is open by default and a public Rallly with registration open
is an account for anyone who can receive mail. Voting on a poll never needed an account.
```bash
sed -i 's/^REGISTRATION_ENABLED=true$/REGISTRATION_ENABLED=false/' /srv/rallly/.env
cd /srv/rallly
docker compose up -d --force-recreate rallly
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/login | grep -c '>Login to your account to continue</p>'
curl -sS https://<DOMAIN>/login | grep -c '>Log in to your account or create a new one</p>'
```
You should see: the loop reach `200` again, then `1`, then `0`.
If you do not: a second `1` where you expected `0` means the container came back with the old
value, so check that the `sed` line actually changed the file with
`grep REGISTRATION_ENABLED /srv/rallly/.env` and recreate again. That is a line you can paste back
here safely; the two lines above it are not.
## 8. First backup and restore
Two artifacts. The database holds every poll, vote, comment and account. The config archive holds
what rebuilds the service around it.
```bash
cd /srv/rallly
docker compose exec -T postgres pg_dump -U rallly -d rallly | gzip > /srv/rallly/backups/rallly-db-$(date +%F).sql.gz
sudo tar -czf /srv/rallly/backups/rallly-config-$(date +%F).tar.gz -C /srv/rallly compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/rallly/backups/
```
You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline, because
`pg_dump` snapshots a running database consistently.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and the
shell created the file anyway. Run the dump line without `| gzip` to read the error.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/rallly
scp vps:/srv/rallly/backups/* ~/backups/rallly/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/rallly/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is one account:
```bash
cd /srv/rallly
docker compose down
sudo rm -rf /srv/rallly/postgres
sudo install -d -m 700 /srv/rallly/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/rallly/backups/rallly-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U rallly -d rallly
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/api/status
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then a status object whose `database`
field reads `connected`. Sign in again with the same address to confirm your account survived.
If you do not: `role "rallly" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what the archive is for
as well: `SECRET_PASSWORD` lives in .env, every signed-in session is sealed with it, and a
database restored beside a freshly generated key logs everybody out at once.
## 9. Updating later
New versions are listed at https://github.com/lukevella/rallly/releases. Take both backup
artifacts first, then edit the `image:` line in /srv/rallly/compose.yml to the new tag and its
digest.
```bash
cd /srv/rallly
docker compose pull
docker compose up -d
docker compose logs --tail 40 rallly
```
You should see: migration output, then the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Then re-run the
`/api/status` check from step 7 before you call the update done. Stay inside the 4.x line: the
licence upstream sells is perpetual for 4.x, and a major bump is a separate decision.
## 10. What will probably go wrong
The sign-in code will not arrive, and nothing will look broken. I sat on the `Verify your email`
screen for ten minutes with a healthy container, a `200` from `/api/status` and an empty inbox,
and the Rallly log said nothing, because the app had handed the message to the relay and the relay
had refused it out of sight. Make that conversation visible: add `SMTP_DEBUG=true` to
/srv/rallly/.env, run `docker compose up -d --force-recreate rallly`, try the sign-in again, and
read `docker compose logs --tail 60 rallly`. Mine was rejecting the from-address because the
domain was not verified with the relay yet. Take `SMTP_DEBUG` out once mail lands: it prints the
whole SMTP exchange into the log.
## 11. Out of scope
- Do not configure single sign-on. OIDC, Google and Microsoft each need a client registered in
somebody else's console, and this install signs people in by email.
- Do not add upstream's bundled Traefik or Garage containers. Caddy already terminates TLS here,
and the object store buys avatar and logo uploads at the price of a third service to operate.
- Do not set `APP_NAME`, `LOGO_URL` or `HIDE_ATTRIBUTION`. Those need a purchased licence key, and
this install runs the free one.
- Do not install a mail server on this box. Upstream tells you to use a transactional provider,
and port 25 on a fresh VPS is a fight with no prize.330 lines · 14,977 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Rallly 4.12.1, with the PostgreSQL it stores polls in, under ~/selfhost/rallly,
answering at http://localhost:8153.
## 1. Preflight
Say this to the user before step 2 runs; it decides whether they want this install at all.
Rallly's product is a link a group opens to vote on a time, and every link here begins with
http://localhost:8153, which means "this computer" wherever it is read. Nobody they invite can
open it, their own phone included. They get a scheduling tool for themselves, not a poll a group
can answer.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. This install needs 2048 MB of RAM available
and 5 GB free on the home disk, upstream's own floor, and both images publish amd64 and arm64. On
macOS and Windows that figure is the host's, and Docker Desktop's virtual machine takes its cut of
it. If either floor is missed, print both numbers and stop.
Settle one thing more, because step 4 stops dead without it. Rallly signs people in by mailing a
six-digit code and offers no other way in, so even here it needs an SMTP relay. Upstream names
Resend, Postmark, Mailgun and Brevo, and says not to use a Gmail or Proton mailbox, because
consumer inboxes rate-limit automated senders and block sign-in mail. Have the user find a host,
port, username and password first.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/rallly/backups
ls -la ~/selfhost/rallly
```
Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: polls, votes and
accounts are rows in PostgreSQL, which step 5 keeps in a volume Docker manages, so no ownership
fix is needed anywhere.
## 4. Secrets
Two secrets: the PostgreSQL password and the session key. Generate both here, print neither, keep
both out of your summary and any log line. Upstream documents `openssl rand -hex 32` for
`SECRET_PASSWORD`, which rejects anything shorter than 32 characters.
```bash
cd ~/selfhost/rallly
umask 077
cat > .env <<EOF
NEXT_PUBLIC_BASE_URL=http://localhost:8153
POSTGRES_PASSWORD=$(openssl rand -hex 32)
SECRET_PASSWORD=$(openssl rand -hex 32)
REGISTRATION_ENABLED=true
SUPPORT_EMAIL=CHANGE_ME
INITIAL_ADMIN_EMAIL=CHANGE_ME
SMTP_HOST=CHANGE_ME
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=CHANGE_ME
SMTP_PWD=CHANGE_ME
EOF
chmod 600 .env
umask 022
ls -l .env
```
Assert: mode `-rw-------`. Git Bash ships openssl; on Windows the mode bits are advisory and the
real boundary is the user's own account. `SUPPORT_EMAIL` is validated as an email at start-up, so
the container refuses to boot while it reads `CHANGE_ME`. `INITIAL_ADMIN_EMAIL` is the address
allowed to claim admin in step 7, and `REGISTRATION_ENABLED` is true until step 7 closes it.
STOP: tell the user to open ~/selfhost/rallly/.env in an editor, replace every `CHANGE_ME`,
correct `SMTP_PORT` if their relay is not 587 and set `SMTP_SECURE=true` if it is 465, and save.
Do not continue until they confirm, and never ask them to paste those values to you.
```bash
grep -c CHANGE_ME .env || true
```
Assert: that prints `0`. It counts lines, never values.
## 5. compose.yml
```bash
cat > ~/selfhost/rallly/compose.yml <<'EOF'
# Rallly · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://support.rallly.co/self-hosting/installation/docker
# configuration ...... https://support.rallly.co/self-hosting/configuration
#
# Two services on the computer you are sitting at. Paths are relative to
# ~/selfhost/rallly/, so one file works on all three systems. The database is a
# named volume, not a bind mount, because the PostgreSQL image chowns its data
# directory to a uid Docker Desktop cannot grant on a Windows home directory.
# Upstream's stack adds a Traefik and a Garage object store; neither runs here.
# Digests read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: rallly-db
restart: unless-stopped
environment:
POSTGRES_DB: rallly
POSTGRES_USER: rallly
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- rallly-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rallly -d rallly"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
rallly:
image: lukevella/rallly:4.12.1@sha256:6049260ff6d3accd86730372a650b5e8063c373a09f253c45f7e4a8dc9202752
container_name: rallly
restart: unless-stopped
env_file: ./.env
environment:
# Built here rather than kept in .env so the password appears once.
DATABASE_URL: postgres://rallly:${POSTGRES_PASSWORD}@postgres:5432/rallly
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/status || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
ports:
# Loopback only: no other device on the wifi can reach 8153.
- "127.0.0.1:8153:3000"
depends_on:
postgres:
condition: service_healthy
volumes:
rallly-pgdata:
EOF
cd ~/selfhost/rallly && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Two services, one published port, one named volume, no password:
compose reads `${POSTGRES_PASSWORD}` out of ./.env.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve. A certificate attests a public name and nothing here has one; browsers treat
http://localhost as a secure context anyway. Nothing is published beyond loopback: 8153 is bound
to 127.0.0.1, this computer only, and not the user's phone or anyone they want to meet. Confirm:
```bash
grep -c '"127.0.0.1:' ~/selfhost/rallly/compose.yml
```
Assert: that prints `1`, the published-port line `- "127.0.0.1:8153:3000"`. PostgreSQL publishes no
host port, so 5432 cannot appear.
## 7. Start and verify
The container runs its own Prisma migrations before the server listens, so the first boot takes
minutes.
```bash
cd ~/selfhost/rallly
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8153/api/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8153/api/status
curl -sS http://localhost:8153/login | grep -c '>Log in to your account or create a new one</p>'
```
Assert all three, and print what you received for each. The loop ends on `200`. The status body is
a small JSON object containing `"status":"ok"` and `"database":"connected"`. The grep prints `1`.
If any misses, stop, run `docker compose logs --tail 60 rallly`, and name the cause. Both usual
ones point at step 4: `Invalid environment variables` means a `CHANGE_ME` survived or
`SECRET_PASSWORD` is too short, and a database that never reports healthy means an empty
`POSTGRES_PASSWORD`. `port is already allocated` means something else holds 8153, so stop until
the user frees it. A running container is not success.
The first screen at http://localhost:8153/login is headed `Welcome`, with
`Log in to your account or create a new one` under it and one box asking for an email address.
STOP: tell the user to open http://localhost:8153/login, enter the address they put in
`INITIAL_ADMIN_EMAIL`, type in the six-digit code Rallly mails to it, then open
http://localhost:8153/control-panel and press the button that makes them an admin.
Do not continue until they confirm both. That code arriving is the only proof the relay from
step 4 works.
Once they confirm, close registration and prove it is closed:
```bash
cd ~/selfhost/rallly
sed 's/^REGISTRATION_ENABLED=true$/REGISTRATION_ENABLED=false/' .env > .env.next && mv .env.next .env
chmod 600 .env
docker compose up -d --force-recreate rallly
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8153/api/status); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8153/login | grep -c '>Login to your account to continue</p>'
curl -sS http://localhost:8153/login | grep -c '>Log in to your account or create a new one</p>' || true
```
Assert: the loop reaches `200` again, the first grep prints `1` and the second prints `0`. Both
must pass before you report success; the rewrite avoids `sed -i`, spelled differently on macOS.
## 8. First backup and restore
Two artifacts: a dump holding every poll, vote and account, and a config archive with the two
files that rebuild the service.
```bash
cd ~/selfhost/rallly
docker compose exec -T postgres pg_dump -U rallly -d rallly | gzip > ~/selfhost/rallly/backups/rallly-db-$(date +%F).sql.gz
tar -C ~/selfhost/rallly -czf ~/selfhost/rallly/backups/rallly-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/rallly/backups/
```
Assert: both exist and are non-empty. Print both sizes. Nothing stops: `pg_dump` snapshots a
running database consistently.
Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a synced
folder or a USB stick, and copy both there with `cp`.
Assert: the user confirms both filenames are there, or say plainly this install has no backup.
To restore, in this order. In ~/selfhost/rallly, untar the config archive first so compose.yml and
.env are back before any container starts: PostgreSQL reads `POSTGRES_PASSWORD` from .env the
moment it initialises an empty volume. Then `docker compose down -v`, the one place `-v` belongs,
`docker compose up -d postgres`, wait 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz`
into `docker compose exec -T postgres psql -U rallly -d rallly`, then `docker compose up -d`. The
archive matters as much as the dump: `SECRET_PASSWORD` in .env seals every session.
## 9. Updating later
New versions are listed at https://github.com/lukevella/rallly/releases. Take both backups first,
then edit the image line in compose.yml to the new tag and digest:
```bash
cd ~/selfhost/rallly
docker compose pull
docker compose up -d
docker compose logs --tail 40 rallly
```
Rallly migrates on the way up, so watch that log until it settles, then re-run step 7's checks.
Stay inside the 4.x line: a major bump is a separate decision.
## 10. What will probably go wrong
I rebooted this machine, opened the poll I had made the day before, and got a connection error
that reads exactly like a lost database. It was not. Docker Desktop had not started with the
session, nothing was listening on 8153, and `restart: unless-stopped` only acts once the Docker
daemon is up. Turn on its start-at-login setting, and after a reboot run
`cd ~/selfhost/rallly && docker compose up -d` before concluding anything is broken.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `NEXT_PUBLIC_BASE_URL` to this machine's LAN address and do not rebind 8153 to
0.0.0.0 so a colleague can vote. That puts an app that mails sign-in codes on every network the
user joins.
- Do not configure single sign-on or add upstream's Traefik and Garage containers.compose.local.ymlthe services, pinned · local layout54 lines
# Rallly · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://support.rallly.co/self-hosting/installation/docker
# configuration ...... https://support.rallly.co/self-hosting/configuration
#
# Two services on the computer you are sitting at. Paths are relative to
# ~/selfhost/rallly/, so one file works on all three systems. The database is a
# named volume, not a bind mount, because the PostgreSQL image chowns its data
# directory to a uid Docker Desktop cannot grant on a Windows home directory.
# Upstream's stack adds a Traefik and a Garage object store; neither runs here.
# Digests read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: rallly-db
restart: unless-stopped
environment:
POSTGRES_DB: rallly
POSTGRES_USER: rallly
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- rallly-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rallly -d rallly"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
rallly:
image: lukevella/rallly:4.12.1@sha256:6049260ff6d3accd86730372a650b5e8063c373a09f253c45f7e4a8dc9202752
container_name: rallly
restart: unless-stopped
env_file: ./.env
environment:
# Built here rather than kept in .env so the password appears once.
DATABASE_URL: postgres://rallly:${POSTGRES_PASSWORD}@postgres:5432/rallly
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/status || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
ports:
# Loopback only: no other device on the wifi can reach 8153.
- "127.0.0.1:8153:3000"
depends_on:
postgres:
condition: service_healthy
volumes:
rallly-pgdata:agent-readable mirror: /self-host/doodle.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned54 lines
# Rallly · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://support.rallly.co/self-hosting/installation/docker
# configuration ...... https://support.rallly.co/self-hosting/configuration
# entrypoint ......... https://github.com/lukevella/rallly/blob/v4.12.1/scripts/docker-start.sh
# status endpoint .... https://github.com/lukevella/rallly/blob/v4.12.1/apps/web/src/app/api/status/route.ts
#
# Two services: Rallly and the PostgreSQL that holds every poll, vote, comment
# and account. Upstream's own stack adds a bundled Traefik and a Garage object
# store, and this file runs neither: Caddy on the host terminates TLS, and the
# S3 variables are optional, so leaving them unset costs avatar and logo
# uploads and nothing else. The container runs its own Prisma migrations before
# the server listens, which the health check's start period allows for. Digests
# read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: rallly-db
restart: unless-stopped
environment:
POSTGRES_DB: rallly
POSTGRES_USER: rallly
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/rallly/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rallly -d rallly"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
rallly:
image: lukevella/rallly:4.12.1@sha256:6049260ff6d3accd86730372a650b5e8063c373a09f253c45f7e4a8dc9202752
container_name: rallly
restart: unless-stopped
env_file: /srv/rallly/.env
environment:
# Built here rather than kept in .env so the password appears once.
DATABASE_URL: postgres://rallly:${POSTGRES_PASSWORD}@postgres:5432/rallly
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/status || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8153.
- "127.0.0.1:8153:3000"
depends_on:
postgres:
condition: service_healthyCaddyfilethe hostname and TLS29 lines
# Rallly · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://support.rallly.co/self-hosting/installation/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also NEXT_PUBLIC_BASE_URL in .env, and the container reads it to build the
# poll links it mails out, so the two have to agree exactly.
<DOMAIN> {
# Every invitee opens a page served from here, and the sign-in code arrives
# by mail, so a downgrade attack has a real audience. Nothing here is meant
# to be embedded in another site, so framing stays same-origin.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
encode zstd gzip
# 8153 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8153
}install.shthe same install, no agent177 lines
#!/usr/bin/env bash
# Rallly · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=meet.example.com \
# SUPPORT_EMAIL=you@example.com \
# ADMIN_EMAIL=you@example.com \
# SMTP_HOST=smtp.example.com SMTP_USER=apikey SMTP_PWD=... ./install.sh
#
# SMTP_PWD is read from the environment and never written to your terminal.
# Start that command line with a space if your shell records history.
#
# Authored by caniselfhostit from the upstream documentation:
# https://support.rallly.co/self-hosting/installation/docker
# https://support.rallly.co/self-hosting/configuration
# https://support.rallly.co/self-hosting/control-panel
# https://support.rallly.co/self-hosting/licensing
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# the session key. Both go into /srv/rallly/.env with mode 600 and neither is
# ever printed.
#
# DOMAIN_HOST is also NEXT_PUBLIC_BASE_URL, the address inside every invitation
# this instance mails. Choose it once. Changing it later breaks links other
# people are already holding.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/rallly}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
SUPPORT_EMAIL="${SUPPORT_EMAIL:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
SMTP_HOST="${SMTP_HOST:-}"
SMTP_PORT="${SMTP_PORT:-587}"
SMTP_SECURE="${SMTP_SECURE:-false}"
SMTP_USER="${SMTP_USER:-}"
SMTP_PWD="${SMTP_PWD:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. meet.example.com"
[ -n "$SUPPORT_EMAIL" ] || die "set SUPPORT_EMAIL; Rallly validates it as an email address at start-up"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address that will claim the admin role"
[ -n "$SMTP_HOST" ] || die "set SMTP_HOST; sign-in codes arrive by mail and there is no other way in"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; upstream's floor is 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex for both. Upstream documents `openssl rand -hex 32` for the session key
# and rejects anything shorter than 32 characters; the database password rides
# inside a connection string, where hex needs no escaping. Read them later with
# sudo grep -E 'POSTGRES_PASSWORD|SECRET_PASSWORD' /srv/rallly/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
NEXT_PUBLIC_BASE_URL=https://${DOMAIN_HOST}
POSTGRES_PASSWORD=$(openssl rand -hex 32)
SECRET_PASSWORD=$(openssl rand -hex 32)
REGISTRATION_ENABLED=true
SUPPORT_EMAIL=${SUPPORT_EMAIL}
INITIAL_ADMIN_EMAIL=${ADMIN_EMAIL}
SMTP_HOST=${SMTP_HOST}
SMTP_PORT=${SMTP_PORT}
SMTP_SECURE=${SMTP_SECURE}
SMTP_USER=${SMTP_USER}
SMTP_PWD=${SMTP_PWD}
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-rallly"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and neither 8153 nor 5432 is one of them ------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8153 and 5432 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# The container applies its own Prisma migrations before the server listens, so
# a first boot takes minutes rather than seconds.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/api/status"
for _ in $(seq 1 40); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/status" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/api/status answered ${code:-nothing}. Check: docker compose logs --tail 60 rallly"
status="$(curl -sS "https://${DOMAIN_HOST}/api/status" || true)"
case "$status" in
*'"status":"ok"'*) : ;;
*) die "/api/status answered 200 without status ok. Check: docker compose logs --tail 60 rallly" ;;
esac
case "$status" in
*'"database":"connected"'*) : ;;
*) die "the app is up but reports the database disconnected. Check: docker compose logs --tail 20 postgres" ;;
esac
# The login page has to render the sign-up wording while registration is open.
login_open="$(curl -sS "https://${DOMAIN_HOST}/login" | grep -c '>Log in to your account or create a new one</p>' || true)"
[ "$login_open" = "1" ] || die "https://${DOMAIN_HOST}/login did not render the expected first screen"
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U rallly -d rallly | gzip > "$APP_DIR/backups/rallly-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/rallly-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/rallly-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
Rallly is answering at https://${DOMAIN_HOST}/login
1. Sign in at https://${DOMAIN_HOST}/login with ${ADMIN_EMAIL}. Rallly
mails you a six-digit code; that code arriving is the only proof your
relay works. If it does not, add SMTP_DEBUG=true to $APP_DIR/.env,
recreate the container, and read the log.
2. Then open https://${DOMAIN_HOST}/control-panel and press the button
that makes you an admin.
3. Registration is still open, which on a public hostname is an account
for anyone who can receive mail. Once you are signed in, close it:
cd $APP_DIR
sed -i 's/^REGISTRATION_ENABLED=true\$/REGISTRATION_ENABLED=false/' .env
docker compose up -d --force-recreate rallly
Then confirm the login page says "Login to your account to continue".
4. First backup written to $APP_DIR/backups: a database dump and a config
archive. They are on the same disk as the data, which is not a backup.
Copy them somewhere else tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Doodle.
- The code is AGPL-3.0 and the multi-user tiers are sold. Upstream says Rallly is free for personal use and asks you to buy a licence key for a multi-user setup: $49 for up to five people, $299 for up to fifty, from $999 above that, each perpetual for the 4.x line. It is an honour system, nothing stops working if you go over, and guest voters never count. The branding variables that rename the app, swap the logo and hide the attribution are the ones that need a licence to take effect.
- Mail is the product, not a nicety. Signing in is a six-digit code emailed to you, there is no password to fall back on, and a relay that silently refuses your messages is a locked front door. This install stops and asks for relay credentials before it starts anything, and upstream is explicit that a Gmail or Proton mailbox is not a relay.
- You own a PostgreSQL and one key. Every poll, option, vote and comment is a row in that database, and SECRET_PASSWORD from your .env seals every signed-in session. Back the dump and the .env up together, because a database restored beside a freshly generated key logs everyone out at once.
- No avatar or logo uploads. Upstream's own stack runs a Garage object store for those; this install leaves the S3 variables unset and runs two containers instead of four. Polls, votes and comments are untouched by that trade.
- The image checks in with rallly.co. The published self-hosted build ships with an API base URL pointing at Rallly's own service, which a cloud or development build does not have, so a running instance makes outbound calls yours would otherwise never make.
Where this came from
“Rallly is open-source and always free for personal use. If you want to run a multi-user setup, we ask that you purchase a license key.”
- Upstream's own self-hosted stack is four containers, a Traefik, the app, PostgreSQL and a Garage object store, and setting PROXY_MODE=external drops the Traefik so an existing reverse proxy can serve the app on a loopback port. source
- SECRET_PASSWORD must be at least 32 characters and SUPPORT_EMAIL is required, while every S3 object-storage variable is optional and unset means no bucket at all. source
- The published image exposes port 3000, carries a health check against /api/status, and runs its own Prisma migrations from an entrypoint script before the server listens. source
- Signing in sends a six-digit one-time code by email, and with registration disabled that request becomes an email-verification one that only reaches accounts which already exist. source
- Multi-user licences are perpetual for the 4.x line, run on an honour system rather than a hard limit, and guest voters never count towards the user total. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Doodle?
Not Doodle itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Rallly. Send a group a link, let them tick the times that work, and pick the winner, with no cap on how many people answer. The install is one weekend: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 210 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Doodle?
Rallly. Send a group a link, let them tick the times that work, and pick the winner, with no cap on how many people answer. The closest thing to what Doodle actually sells: you propose several dates or times, you send one link, and anyone who opens it votes without making an account. The participant count that Doodle's free tier caps and its paid tiers unlock is a row in your own PostgreSQL here, and the ads that sit on a free Doodle page never existed. The honest catch is that sign-in is a code emailed to you, so you are signing up to keep an SMTP relay working, and upstream asks you to buy a licence key once more than one person has an account on the instance. Also evaluated and ranked below it: Cal.com. The prompt on this page installs Rallly only. Rallly is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Doodle?
2048 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Rallly itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Doodle Pro, $10.95/mo — $131.40 a year, 1 seat assumed.
How hard is it really?
ONE WEEKEND — 3–24 hours. The rule that produced that verdict: a database plus one outside integration. A database and an outside service fail in completely different ways, and you have to learn both failure modes before you trust the thing with real data. Budget the second day for whichever one surprises you. The tier is derived from seven countable facts about the Rallly install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Rallly on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Rallly on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Every poll link this makes begins with http://localhost:8153, which resolves on this computer and nowhere else, so the group you wanted to poll gets a connection error instead of a list of times. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.