Skip to Content

Production Guide: Deploy Rallly with Docker Compose + Caddy + PostgreSQL + SMTP on Ubuntu

A practical production deployment pattern for private scheduling polls with TLS, SMTP, PostgreSQL, backups, and recovery checks.

Scheduling meetings looks simple until every client, contractor, and internal team uses a different calendar habit. Rallly is a lightweight open-source scheduling poll application that lets you propose time slots, collect availability, and avoid sending ten back-and-forth emails. This guide deploys Rallly as a production service on Ubuntu using Docker Compose, PostgreSQL for durable data, Caddy for automatic HTTPS, and SMTP for invites and notifications.

The pattern below is designed for small teams, agencies, and internal operations groups that want a private scheduling endpoint such as schedule.example.com. It avoids fragile demo defaults: secrets are kept outside the Compose file, PostgreSQL has a health check, Caddy terminates TLS, backups are scheduled with systemd, and verification steps prove the application is actually reachable before you send it to users.

Architecture and flow overview

The deployment has three moving parts. Caddy listens publicly on ports 80 and 443, obtains certificates, and proxies traffic to the Rallly container. Rallly serves the web application and talks only to the internal PostgreSQL service over the Compose network. SMTP is an external dependency: Rallly uses it to send invitations, confirmations, and support messages. PostgreSQL data and backups remain on the host under /opt/rallly, which makes recovery and migration straightforward.

Keep the public surface small. Only Caddy should be exposed to the internet. PostgreSQL must not publish a host port, and the application container should not be reachable directly from outside the server. If you later add Cloudflare, an SSO gateway, or a VPN, leave the internal application topology the same and place the access control in front of Caddy or at the edge.

Prerequisites

  • An Ubuntu 22.04 or 24.04 server with at least 1 vCPU, 1 GB RAM, and 10 GB free disk.
  • A DNS record, for example schedule.example.com, pointing at the server.
  • SMTP credentials from a provider that permits application mail.
  • Root or sudo access and a basic firewall policy.
  • A recovery location for database backups, even if the first version stores local copies.

Step-by-step deployment

1) Install Docker, Compose, Caddy, and firewall rules

Start with a clean host and install only the runtime components required by this stack. The firewall allows SSH plus web traffic; PostgreSQL remains private inside Docker.

sudo apt update
sudo apt install -y ca-certificates curl gnupg ufw
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin caddy
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable

If the copy button is unavailable, select the block and copy it manually.

2) Create the application layout and strong secrets

Use a dedicated directory so configuration, persistent database files, and backups are easy to audit. Generate secrets on the server rather than pasting values into chat tools or tickets.

sudo mkdir -p /opt/rallly/{postgres,backups}
sudo chown -R $USER:$USER /opt/rallly
cd /opt/rallly
openssl rand -hex 32 > secret_key.txt
openssl rand -base64 24 > postgres_password.txt
chmod 600 secret_key.txt postgres_password.txt

If the copy button is unavailable, select the block and copy it manually.

3) Write environment values

Replace the domain, support email, and SMTP settings with your real values. The two generated secrets are injected into the file after creation. Keep .env mode 600 and avoid committing it to Git.

cat > /opt/rallly/.env <<'EOF'
DOMAIN=schedule.example.com
NEXT_PUBLIC_BASE_URL=https://schedule.example.com
POSTGRES_DB=rallly
POSTGRES_USER=rallly
POSTGRES_PASSWORD=replace-with-postgres-password
DATABASE_URL=postgres://rallly:replace-with-postgres-password@postgres:5432/rallly
SECRET_PASSWORD=replace-with-secret-key
[email protected]
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
[email protected]
SMTP_PASSWORD=replace-with-smtp-password
SMTP_FROM="Scheduling <[email protected]>"
EOF
sed -i "s|replace-with-postgres-password|$(cat /opt/rallly/postgres_password.txt)|g" /opt/rallly/.env
sed -i "s|replace-with-secret-key|$(cat /opt/rallly/secret_key.txt)|g" /opt/rallly/.env
chmod 600 /opt/rallly/.env

If the copy button is unavailable, select the block and copy it manually.

4) Define the Docker Compose stack

The PostgreSQL service receives a health check so Rallly does not start against a database that is still initializing. The app exposes port 3000 only to the Docker network by default; if your Caddy package cannot reach Docker services directly, publish 127.0.0.1:3000:3000 on the Rallly service instead.

cat > /opt/rallly/docker-compose.yml <<'EOF'
services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5

  rallly:
    image: lukevella/rallly:latest
    restart: unless-stopped
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
    expose:
      - "3000"
EOF
docker compose -f /opt/rallly/docker-compose.yml pull
docker compose -f /opt/rallly/docker-compose.yml up -d

If the copy button is unavailable, select the block and copy it manually.

5) Configure Caddy and start HTTPS

Update the hostname before reloading Caddy. In many environments Caddy runs on the host while Rallly runs in Docker, so you may need to add a localhost port binding on the app service. Validate the file before reloads to avoid taking the proxy offline with a syntax error.

sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
schedule.example.com {
  encode zstd gzip
  reverse_proxy 127.0.0.1:3000
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    X-Content-Type-Options "nosniff"
    Referrer-Policy "strict-origin-when-cross-origin"
  }
}
EOF
# If Docker is not publishing port 3000, bind it to localhost or put Caddy on the Docker network.
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

If the copy button is unavailable, select the block and copy it manually.

Configuration and secrets handling best practices

Separate operational configuration from the Compose file. The Compose file should describe services and volumes; the .env file should hold deployment-specific values. Treat SMTP credentials like production secrets because they can send email as your domain. Rotate them if they are pasted into a ticket, shared screen, or shell history.

Use a dedicated SMTP identity for Rallly instead of a personal mailbox. Set SPF, DKIM, and DMARC for the sender domain so invitations do not land in spam. Keep NEXT_PUBLIC_BASE_URL aligned with the public HTTPS URL; mismatches can produce broken links in invite emails even when the site works in a browser.

Pin image versions after your first successful launch if you need strict change control. latest is convenient for a guide, but production environments should record a tested image tag, test upgrades in a staging copy, and retain a database dump before pulling new images.

Verification checklist

Do not stop after docker compose up -d. Verify containers, logs, TLS, page rendering, and mail delivery. A scheduling tool is not production-ready until invite emails arrive and links point to the public domain.

cd /opt/rallly
docker compose ps
docker compose logs --tail=80 rallly
curl -I https://schedule.example.com
curl -s https://schedule.example.com | head -c 200
# Create a test poll, invite a test address, and confirm the email arrives before sharing broadly.

If the copy button is unavailable, select the block and copy it manually.

  • The Caddy certificate is issued without HTTP challenge errors.
  • The Rallly page loads through HTTPS and does not redirect to an internal hostname.
  • A test poll can be created, shared, completed, and deleted.
  • SMTP sends to at least one internal and one external mailbox.
  • Backups run successfully and produce non-empty gzip files.

Backups and recovery routine

Rallly data lives in PostgreSQL, so logical dumps are the simplest recovery unit. Local retention is useful for quick mistakes, but it is not a disaster recovery plan. Copy the resulting files to object storage, another server, or your existing backup platform.

sudo tee /usr/local/bin/backup-rallly >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/rallly
stamp=$(date -u +%Y%m%dT%H%M%SZ)
docker compose exec -T postgres pg_dump -U rallly rallly | gzip > backups/rallly-${stamp}.sql.gz
find backups -type f -name 'rallly-*.sql.gz' -mtime +14 -delete
EOF
sudo chmod +x /usr/local/bin/backup-rallly
sudo tee /etc/systemd/system/rallly-backup.service >/dev/null <<'EOF'
[Unit]
Description=Backup Rallly PostgreSQL database
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-rallly
EOF
sudo tee /etc/systemd/system/rallly-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Nightly Rallly backup
[Timer]
OnCalendar=*-*-* 02:20:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now rallly-backup.timer
sudo systemctl start rallly-backup.service

If the copy button is unavailable, select the block and copy it manually.

Test restore on a separate machine before relying on the timer. A backup you have never restored is only a guess. During upgrades, take an on-demand dump, pull the new image, restart the stack, and keep the old dump until you have created and answered a test poll.

Common issues and fixes

Caddy cannot issue a certificate

Confirm the DNS record points to the server, ports 80 and 443 are open, and no other service is already bound to those ports. If you use Cloudflare proxying, temporarily disable strict edge rules until the origin certificate flow is confirmed.

Invite emails never arrive

Check the Rallly logs first, then verify SMTP host, port, username, password, and TLS mode. Some providers require app passwords or verified sender identities. Also check SPF and DKIM because technically successful sends can still be quarantined by recipient systems.

Links in email point to the wrong host

Review NEXT_PUBLIC_BASE_URL and restart the application container after changing it. The public URL should include https:// and the exact hostname users open in the browser.

The site shows a gateway error

Run docker compose ps and check whether the Rallly container is healthy. If Caddy runs on the host, make sure the app is reachable on localhost or move Caddy into the same Docker network. A reverse proxy cannot connect to a container name unless it shares that network.

Database startup fails after a reboot

Look for permission problems in /opt/rallly/postgres. The directory should be owned consistently by the user running Docker-managed files. Avoid editing database files directly; use pg_dump and restore workflows instead.

FAQ

Can Rallly replace a full calendar booking platform?

Rallly is best for polling availability and choosing a shared time. If you need paid bookings, deep calendar write access, or complex routing rules, pair it with a booking platform instead of forcing those workflows into a poll tool.

Should I put Rallly behind SSO?

For internal-only scheduling, yes. Use an edge access layer, VPN, or identity-aware proxy. For client-facing polls, keep the application public but monitor abuse and use a dedicated sender address.

Can PostgreSQL run outside Docker?

Yes. Replace DATABASE_URL with the managed database connection string, remove the local PostgreSQL service, and confirm firewall rules allow only the application host to connect.

How often should I back up the database?

Nightly backups are enough for most scheduling use cases. Increase frequency if Rallly becomes part of a high-volume sales or support workflow where losing a day of polls would be painful.

What should I monitor?

Monitor HTTPS availability, container restarts, disk usage, backup age, and SMTP failures. A simple external check against the public URL plus a daily backup freshness alert catches most operational problems.

How do I upgrade safely?

Take a database dump, record the current image version, pull the new image during a quiet window, restart, and create a test poll. Roll back by restoring the previous image and database dump if migrations fail.

Is local file storage required?

Rallly’s critical state is PostgreSQL. Keep the Compose directory and environment files backed up as infrastructure configuration, but prioritize the database dump for recovery.

Internal links

Talk to us

If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.

Contact Us

Production Guide: Deploy Forgejo with Docker Compose + Caddy + PostgreSQL + SSH on Ubuntu
Run a lightweight Git service with external HTTPS, SSH clone support, backups, and operational checks.