# Can I self-host Doodle?

**YES, BUT** — it's called Rallly. ONE WEEKEND setup · ~3.5 hours to running · 2 GB RAM minimum · $10.95/mo you stop paying ($131.40/yr on the Pro plan).

Rallly authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/doodle/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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.

DONE
```

## Also evaluated

Ranked below Rallly for this swap. The prompts above install Rallly only.

- **Cal.com** — 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 page this mirrors: https://caniselfhostit.com/self-host/doodle/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
