Skip to content

1. Host ​

Step 1 of the setup path: get AsyncUp running on a public HTTPS URL. From a 5-person team on a $5 box to a locked-down production deployment. Pick your path with the matrix, follow the walkthrough, finish with the production checklist. Upgrades and backups are on Operations.

Which setup should I pick? ​

Your situationRecommended pathDatabase~Cost/mo
Small team (≤25), want the simplest thing that worksPath A — VPS + SQLiteembedded SQLite$4–6
You already run (or want) real Postgres opsPath B — VPS + PostgreSQLbundled or managed$4–20
No server to maintain, everPath C — Cloud Runmanaged Postgres~$0–10
Homelab / Raspberry Pi / behind NATPath D — home server + tunnelembedded SQLite$0 + power
Company platform (K8s, ECS, …)your platform + production checklistmanaged Postgresvaries

Real numbers, so you can stop worrying about scale ​

A 10-person team posting daily generates roughly:

  • ~10 form submissions + ~40 webhook calls per workday — seconds of CPU
  • ~2,600 submissions/year ≈ 2–3 MB of database growth
  • Peak memory ~150 MB RSS regardless of team size

A 100-person org across 10 standups is still ~25 MB/year and idles the same. Performance never decides this choice — every path above handles hundreds of users on minimum hardware. Choose by operations:

  • SQLite = one file. Backup is cp, restore is cp. Perfect until you need point-in-time recovery or someone else managing durability.
  • Managed Postgres = automated backups, PITR, failover — someone else's pager. Worth it when losing a week of standups would actually hurt, or when company policy says "no databases on VMs".
  • Bundled Postgres on the same box is mostly a stepping stone: you get Postgres semantics but still own backups. Prefer SQLite (simpler) or managed (safer) unless you specifically want it.

So: 10 people putting daily updates? Path A. Genuinely. Move to managed Postgres when the standup history becomes something you'd be sad to lose — it's a one-line DATABASE_URL change and the schema recreates itself.


Path A — VPS + SQLite (the default) ​

Everything from "I have nothing" to AsyncUp on your own domain with automatic HTTPS. No prior server experience assumed — about 30 minutes.

your team's Google Chat ⇄ https://standup.example.com
                              │  Caddy (automatic HTTPS)
                              ▼
                          AsyncUp container (SQLite inside)

A1. Rent a small server ​

Any provider — Hetzner, DigitalOcean, Vultr, Lightsail, OVH, Oracle Cloud's free tier…

  • 1 vCPU, 512 MB+ RAM, 10 GB disk — usually the cheapest tier
  • Ubuntu 24.04 LTS (commands assume it; Debian is identical)
  • amd64 or arm64 — both images are published

You'll get an IP address and SSH access (ssh root@<ip>).

A2. Point a domain at it ​

Create an A record for a subdomain pointing at the server's IP:

TypeNameValue
Astandup<your server IP>

ping standup.example.com should answer from your server before you continue — HTTPS certificates won't issue until DNS resolves.

A3. Basic hardening + Docker ​

bash
apt-get update && apt-get -y upgrade && apt-get -y install unattended-upgrades ufw

ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable

curl -fsSL https://get.docker.com | sh

A4. Deploy AsyncUp + Caddy ​

Caddy obtains and renews Let's Encrypt certificates automatically — you never touch TLS. Three files in /opt/asyncup:

bash
mkdir -p /opt/asyncup && cd /opt/asyncup

compose.yml

yaml
services:
  asyncup:
    image: ghcr.io/asyncup-dev/asyncup:latest
    restart: unless-stopped
    env_file: .env
    environment:
      DB_PATH: /data/standup.db
    volumes:
      - standup-data:/data

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config

volumes:
  standup-data:
  caddy-data:
  caddy-config:

AsyncUp itself exposes no ports to the internet — only Caddy does.

Caddyfile (swap in your domain)

standup.example.com {
    reverse_proxy asyncup:8080
}

.env — two generated secrets. This file lives in /opt/asyncup, next to compose.yml, not in a clone of the repository. Create it with:

bash
printf 'OPERATOR_TOKEN=%s\nSECRET_KEY=%s\n' "$(openssl rand -hex 24)" "$(openssl rand -hex 32)" > .env
chmod 600 .env
cat .env    # copy OPERATOR_TOKEN: you sign in with it

Keep a copy of SECRET_KEY somewhere safe and separate from database backups: it encrypts the secrets stored in the database, and a lost key means pasting them all again. (DASHBOARD_TOKEN is the older name for OPERATOR_TOKEN and still works.)

Start and verify:

bash
docker compose up -d
curl https://standup.example.com/healthz       # → {"ok":true}  (the app answered and reached its database)
curl https://standup.example.com/health/chat   # → {"audience":"unset",…}  until step 2 is done

The first HTTPS request can take ~30s while the certificate issues. If it doesn't: docker compose logs caddy — usual culprits are DNS not pointing here yet, or a provider-level firewall blocking 80/443 in addition to ufw.

Open https://standup.example.com/app and sign in with the operator token. A fresh install opens the guided setup; keep that tab, and continue with step 2: Google Cloud and the Chat app.

A5. Updates and backups ​

Upgrades, backups and restores have their own page, because doing them in the right order matters: Operations. In short, back up with the app stopped (or with SQLite's VACUUM INTO), never by copying standup.db out of a running container, which can miss recent writes.


Path B — VPS + PostgreSQL ​

Same as Path A, with the database swapped. Two flavors:

Create a database on RDS, Cloud SQL, Neon, Supabase, DigitalOcean — anything that speaks Postgres. Then add one line to .env:

DATABASE_URL=postgres://user:password@host:5432/asyncup

docker compose up -d — the schema creates and migrates itself. SQLite is skipped entirely. You inherit the provider's backups, PITR, and failover.

For TLS (RDS, Cloud SQL, Neon, Supabase all use it), append ?sslmode=require — AsyncUp encrypts the connection without trying to verify the provider's private CA, so it connects out of the box. Want strict verification? Set DB_SSL=verify-full and DB_SSL_CA=/path/to/ca-bundle.pem.

B2. Bundled Postgres on the same machine ​

Use the repo's compose file, which ships an optional Postgres 18 service. Clone it into its own directory (not the Path A one), then create .env in it:

bash
git clone https://github.com/asyncup-dev/asyncup /opt/asyncup-pg && cd /opt/asyncup-pg
cp .env.example .env    # set OPERATOR_TOKEN, SECRET_KEY, POSTGRES_PASSWORD
# and: DATABASE_URL=postgres://asyncup:<password>@postgres:5432/asyncup
docker compose --profile postgres up -d

Use a password without special characters, or URL-encode it inside DATABASE_URL (@ becomes %40, / becomes %2F, and so on); POSTGRES_PASSWORD itself stays as typed.

That compose file publishes port 8080 on 127.0.0.1 only; add the Caddy service from Path A for HTTPS. Don't widen the port to every interface: Docker's port publishing bypasses ufw. (Or use Path A's compose file with DATABASE_URL added.) Sizing: 2 GB RAM is comfortable for app + Postgres. Backups become your job:

bash
docker compose exec postgres pg_dump -U asyncup asyncup | gzip > backup-$(date +%F).sql.gz

Path C — Cloud Run, no server at all ​

Closest to zero ops and realistically ~$0–10/month: the container scales to zero between standups.

  1. Database: use managed Postgres (Cloud SQL, or Neon's free tier). Don't put SQLite on Cloud Run — its filesystem is ephemeral and network-mounted volumes don't support SQLite's locking properly.
  2. Deploy ghcr.io/asyncup-dev/asyncup:latest to Cloud Run: min instances 0, max instances 1 (important — see the checklist), port 8080, env vars: DATABASE_URL, OPERATOR_TOKEN, SECRET_KEY.
  3. The in-process scheduler only runs while an instance is alive, so drive it externally: a Cloud Scheduler job (free tier covers it) hitting POST https://<your-run-url>/tick every minute with header Authorization: Bearer <tick token> (generate the token under Settings › API & tokens).
  4. Cloud Run gives you HTTPS out of the box; map a custom domain in its settings if you want one.
  5. Optional: skip the service-account key and let AsyncUp use the Cloud Run service's own identity (Application Default Credentials) for Chat API calls. Group rosters, Directory lookups and Calendar out-of-office still need a pasted key, so most installs paste one anyway.

Webhook events wake the instance on demand; /tick wakes it for prompts, reminders, and deadlines. Ticks are idempotent — missed or overlapping ticks are safe.


Path D — home server / Raspberry Pi behind NAT ​

No public IP needed: a Cloudflare Tunnel makes an outbound-only connection and gives your domain HTTPS for free.

  1. Domain on Cloudflare (free plan) → Zero Trust → Tunnels → create a tunnel, route standup.example.com → http://asyncup:8080. Cloudflare shows the tunnel's token when you create it; put it in .env as TUNNEL_TOKEN=…, beside OPERATOR_TOKEN and SECRET_KEY.
  2. On the Pi/box (arm64 and amd64 both work):
yaml
services:
  asyncup:
    image: ghcr.io/asyncup-dev/asyncup:latest
    restart: unless-stopped
    env_file: .env
    environment:
      DB_PATH: /data/standup.db
    volumes:
      - standup-data:/data

  cloudflared:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN}

volumes:
  standup-data:

No open ports, no port forwarding, no certificates to manage. Everything else (env, settings, backups) is identical to Path A.


Production checklist ​

Whatever path you chose:

  • [ ] Exactly one instance. The scheduler runs in-process — two replicas would double-send prompts. On K8s: replicas: 1 + Recreate strategy; on Cloud Run: max instances 1. (HA isn't needed — a restart loses nothing, and ticks catch up.)
  • [ ] HTTPS in front of /chat/events; the audience (project number and the https://HOST/chat/events URL) saved in Settings › Google Chat, so events are cryptographically verified
  • [ ] OPERATOR_TOKEN + SECRET_KEY set; SECRET_KEY backed up somewhere that is not the database backup
  • [ ] Tick token generated if /tick is internet-reachable
  • [ ] Backups: nightly SQLite snapshot or managed-Postgres automated backups; do one restore drill (Operations)
  • [ ] Monitoring: point any uptime monitor at GET /healthz (it pings the database); alert on non-200
  • [ ] Updates: follow the upgrade procedure — or pin a digest and bump deliberately; watch the repo's releases
  • [ ] Logs: docker compose logs is plain stdout — ship it wherever your logs go, or rely on docker logs retention

Quick reference ​

Path A (VPS+SQLite)Path B1 (managed PG)Path C (Cloud Run)Path D (home)
Monthly cost$4–6$4–6 + DB ($0–15)~$0–10$0
Ops you ownVM updates, file backupVM updatesnonethe hardware
Backupsone file, cronprovider PITRprovider PITRone file, cron
TLSCaddy, automaticCaddy, automaticbuilt-inCloudflare, automatic
Best for≤50 people, simplicityhistory you can't losezero maintenancetinkerers

Released under the MIT License.