Waterflow

Run it on Google Cloud Run

Waterflow on Google Cloud Run with a Supabase database and your own domain, set up from Cloud Shell or the console.

Instead of a machine that stays on, Waterflow can run on Google Cloud Run, which starts the server when someone opens it and stops it when nobody does, with the database on Supabase. For one household this fits in the free tiers: expect about $0 to $1 a month (see Cost). It needs Waterflow 0.1.5 or newer: 0.1.4 starts on Supabase only once (see Troubleshooting).

Every step below has commands for Cloud Shell, the terminal in the Google Cloud console (the >_ button at the top right): nothing to install on your computer. Where the console can do the same, the step says how.

How it fits together

Part What it is
Cloud Run service waterflow The server: the web app and the API, built from this repository. It sleeps when nobody uses it
Cloud Run job waterflow-jobs The daily jobs (purge, backup, exchange rates), started every hour by Cloud Scheduler; it runs whatever is due and stops
Supabase The database, through its session pooler
Secret Manager The database owner’s address, and the passwords of the server’s own database logins
Cloud Storage bucket The backups and the list of purged accounts, kept apart from the database
Cloudflare (or any DNS) Your domain, pointing at the service

Because the server sleeps between visits, it doesn’t run the daily jobs itself (RUN_JOBS=off): the hourly job does, at the times set on Server admin, so each runs within the hour after its time. The hourly visit also keeps a free Supabase project from being paused for inactivity.

Before you start

  • A Google account with billing turned on (the free tier still needs a billing account). Create a new project just for Waterflow: the commands below give the server’s service account roles on the whole project.
  • A Supabase account.
  • Optional: a domain whose DNS you manage (the examples use Cloudflare and money.example.com).

Pick a region that supports custom domains on Cloud Run, close to your Supabase region. Pairs that sit close together:

Supabase region Cloud Run region
Southeast Asia (Singapore) asia-southeast1
Northeast Asia (Tokyo) asia-northeast1
Central EU (Frankfurt) europe-west4 (Netherlands)
East US (North Virginia) us-east4
West US (Oregon) us-west1

The examples use Singapore. Custom domains work only in some Cloud Run regions: the list.

1. The database on Supabase

  1. Create a project in the region you picked. Give it a database password of letters and digits only, so it never needs encoding in an address. Keep it for the next step.
  2. Open Connect, choose Session pooler, and copy the address. It looks like postgresql://postgres.abcdefghijklmnop:[YOUR-PASSWORD]@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres.
  3. Put your password in place of [YOUR-PASSWORD] (brackets included) and add ?sslmode=require at the end.

Use the session pooler, not the others: the direct connection is IPv6-only and Cloud Run can’t reach it, and the transaction pooler (port 6543) can’t run the server’s migrations. Waterflow connects with its own limited logins for everything but migrations and backups, and gives them the same .abcdefghijklmnop suffix the pooler needs (Your own database).

2. The project

Open Cloud Shell in your new project. Save the names the next steps use in a file, changing my-waterflow to your project’s ID and the region if you picked another:

cat > ~/waterflow-cloud-run.sh <<'EOF'
PROJECT=my-waterflow
REGION=asia-southeast1
SA=waterflow@$PROJECT.iam.gserviceaccount.com
BUCKET=$PROJECT-waterflow-backups
IMAGE=$REGION-docker.pkg.dev/$PROJECT/waterflow/waterflow
EOF
source ~/waterflow-cloud-run.sh
gcloud config set project $PROJECT

If Cloud Shell restarts later, run source ~/waterflow-cloud-run.sh again before the next command.

Turn on the services Waterflow uses, and make its image repository and service account:

gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com \
  secretmanager.googleapis.com cloudscheduler.googleapis.com
gcloud artifacts repositories create waterflow --repository-format=docker --location=$REGION
gcloud iam service-accounts create waterflow --display-name=Waterflow
for role in roles/secretmanager.secretAccessor roles/storage.objectUser roles/run.invoker; do
  gcloud projects add-iam-policy-binding $PROJECT --member=serviceAccount:$SA --role=$role --condition=None
done

The service account is who the server and the jobs run as: it can read the secrets, write the backups, and start the jobs (Cloud Scheduler uses it for that).

Each update adds an image of about 400 MB; keep only the newest three:

cat > ~/waterflow-cleanup.json <<'EOF'
[{"name": "keep-newest-3", "action": {"type": "Keep"}, "mostRecentVersions": {"keepCount": 3}},
 {"name": "delete-the-rest", "action": {"type": "Delete"}, "condition": {"tagState": "any"}}]
EOF
gcloud artifacts repositories set-cleanup-policies waterflow --location=$REGION --policy=$HOME/waterflow-cleanup.json --no-dry-run

In the console: APIs & Services, Enable APIs, for Cloud Run, Cloud Build, Artifact Registry, Secret Manager and Cloud Scheduler; Artifact Registry, Create repository (Docker, your region, named waterflow); IAM & Admin, Service accounts, Create (waterflow), then IAM, Grant access to it with the three roles above: Secret Manager Secret Accessor, Storage Object User, Cloud Run Invoker.

3. Secrets

Two secrets. waterflow-db-logins holds the passwords of the server’s own database logins (api_login, purge_login, fx_login): the server sets them in the database every time it starts, so they must stay the same from one start to the next. waterflow-admin-db-url is the Supabase address from step 1.

python3 -c 'import json, secrets; print(json.dumps({k: secrets.token_hex(24) for k in ("api_login", "purge_login", "fx_login")}))' \
  | gcloud secrets create waterflow-db-logins --data-file=-
read -rs DB_URL

Paste the Supabase address and press Enter (nothing shows while you paste), then:

printf '%s' "$DB_URL" | gcloud secrets create waterflow-admin-db-url --data-file=-
unset DB_URL

In the console: Security, Secret Manager, Create secret. For waterflow-db-logins, the value is {"api_login": "…", "purge_login": "…", "fx_login": "…"} with three long random passwords of letters and digits (the python3 line above prints one in Cloud Shell).

4. The backup bucket

gcloud storage buckets create gs://$BUCKET --location=$REGION --uniform-bucket-level-access --public-access-prevention

In the console: Cloud Storage, Buckets, Create: your region, uniform access, public access prevention on.

5. Build the server

Cloud Build builds the image from this repository (main is always the latest release). It takes about five minutes:

git clone https://github.com/LeeMinNguyeen/waterflow ~/waterflow
cd ~/waterflow
gcloud builds submit --tag=$IMAGE:$(cat VERSION) --timeout=30m .

The console has no button for this step: it needs the three lines above in Cloud Shell.

6. The server

cd ~/waterflow
gcloud run deploy waterflow --image=$IMAGE:$(cat VERSION) --region=$REGION --service-account=$SA \
  --allow-unauthenticated --execution-environment=gen2 --cpu=1 --memory=1Gi --cpu-boost \
  --min-instances=0 --max-instances=1 \
  --set-env-vars=RUN_JOBS=off,UPDATE_CHECK=off \
  --set-secrets=ADMIN_DATABASE_URL=waterflow-admin-db-url:latest,/secrets/db_logins.json=waterflow-db-logins:latest \
  --add-volume=type=cloud-storage,bucket=$BUCKET,mount-path=/backups,mount-options=uid=10001

The first start creates everything in the database and prints a setup code (step 9). What the settings mean:

  • --max-instances=1: keep it at 1. Waterflow is one server: its sign-in limits live in its memory.
  • --min-instances=0: it sleeps when unused, so the first visit after a while takes a few seconds. 1 keeps it awake, which costs money every hour.
  • RUN_JOBS=off: the jobs run in step 7 instead. UPDATE_CHECK=off: the update notice on Server admin describes the Docker update, which doesn’t apply here.
  • uid=10001: the server runs as that user, not root, and must be able to write its backups.

In the console: Cloud Run, Deploy container, Service. Container image: the one Cloud Build made (Artifact Registry, waterflow). Region: yours. Authentication: allow public access. Billing: request-based. Scaling: minimum 0, maximum 1. Under Containers:

  • Settings: port 8080, memory 1 GiB, CPU 1, startup CPU boost.
  • Variables & Secrets: add the variables RUN_JOBS = off and UPDATE_CHECK = off, and reference the secret waterflow-admin-db-url as the environment variable ADMIN_DATABASE_URL.
  • Volume mounts: add the two volumes below and mount them.

Under Volumes:

  • A Secret volume: waterflow-db-logins, path db_logins.json, version latest, mounted at /secrets.
  • A Cloud Storage bucket volume: your bucket, mount options uid=10001, mounted at /backups.

Under Security, choose the waterflow service account. Execution environment: second generation.

7. The daily jobs

The same image, started with python -m app.launch jobs: it looks at the schedule once, runs what’s due, and exits.

cd ~/waterflow
gcloud run jobs create waterflow-jobs --image=$IMAGE:$(cat VERSION) --region=$REGION --service-account=$SA \
  --cpu=1 --memory=1Gi --max-retries=0 --task-timeout=30m \
  --command=python --args=-m,app.launch,jobs \
  --set-secrets=ADMIN_DATABASE_URL=waterflow-admin-db-url:latest,/secrets/db_logins.json=waterflow-db-logins:latest \
  --add-volume=type=cloud-storage,bucket=$BUCKET,mount-path=/backups,mount-options=uid=10001
gcloud run jobs execute waterflow-jobs --region=$REGION --wait

The first run does every job (a new server hasn’t run any yet). Its log, and Server admin, show what happened. Then start it every hour:

gcloud scheduler jobs create http waterflow-jobs-hourly --location=$REGION --schedule="7 * * * *" --time-zone=UTC \
  --uri="https://run.googleapis.com/v2/projects/$PROJECT/locations/$REGION/jobs/waterflow-jobs:run" \
  --http-method=POST --oauth-service-account-email=$SA

Run it after step 6: the job needs the server to have set up the database first.

In the console: Cloud Run, Deploy container, Job: the same image, region, service account, secrets and volumes as the server, with container command python and arguments -m, app.launch, jobs; task timeout 30 minutes, 0 retries. Then the job’s Triggers tab, Add scheduler trigger: frequency 7 * * * *, timezone UTC, the waterflow service account.

8. Your domain

The service already has an address ending in .run.app (Cloud Run shows it). For your own, here with Cloudflare:

  1. Prove you own the domain to Google, once per domain:

    gcloud domains verify example.com
    

    It prints a Search Console link: open it, choose Domain, and add the TXT record it shows in Cloudflare’s DNS. Skip this if this Google account already verified the domain.

  2. Map it to the service:

    gcloud beta run domain-mappings create --service=waterflow --domain=money.example.com --region=$REGION
    
  3. In Cloudflare, DNS, add a CNAME record: name money, target ghs.googlehosted.com, proxy status DNS only (the grey cloud). With the orange cloud, Cloudflare stands in the way of Google’s certificate checks and the certificate can fail to be issued or renewed. If your domain has CAA records, allow pki.goog and letsencrypt.org.

Google issues the certificate by itself, usually within 15 minutes, sometimes up to a day; until then the .run.app address works.

In the console: Cloud Run, Domain mappings, Add mapping: the service, then your domain. It walks you through the verification and shows the DNS record to add.

Google lists custom domains on Cloud Run as a preview feature, not recommended for production; for a personal or household server they work.

9. Create the admin

The server printed a setup code in its log:

gcloud run services logs read waterflow --region=$REGION --limit=100

(or Cloud Run, waterflow, Logs). Open your domain or the .run.app address, enter the code, name your server and create your account: it becomes the server’s admin. Each start prints a new code until the admin exists and the old one stops working, so if the server went to sleep in between, use the newest code in the log.

Then turn on two-step verification for the admin (Profile and security): see Limits for why it matters more here.

Updates

When a release comes out (watch the repository’s releases on GitHub to hear about it):

source ~/waterflow-cloud-run.sh
cd ~/waterflow && git pull
gcloud builds submit --tag=$IMAGE:$(cat VERSION) --timeout=30m .
gcloud run deploy waterflow --image=$IMAGE:$(cat VERSION) --region=$REGION
gcloud run jobs update waterflow-jobs --image=$IMAGE:$(cat VERSION) --region=$REGION

The server takes a backup before it applies a release’s database changes. A release that needs a change in these settings says so in its notes.

Server commands

The commands that the Docker guides run with docker compose exec waterflow python -m app.admin run here as a one-off job:

gcloud run jobs execute waterflow-jobs --region=$REGION --wait --args=-m,app.admin,reset-password,USERNAME
gcloud run jobs logs read waterflow-jobs --region=$REGION --limit=20

The second line shows the reset link. make-admin USERNAME and repurge work the same way (Accounts and invites).

Backups

Backups are written every night to the bucket and kept about 6 days, next to purged_accounts.jsonl (the accounts deleted for good; a restore needs it too). They’re already apart from the database. To keep a copy on your computer as well, download the bucket (Cloud Storage, the bucket, Download) or:

gcloud storage cp -r gs://$BUCKET ~/waterflow-backups

Backups contain password hashes: keep the copies private.

Restore into a new, empty Supabase project, then point the server at it:

  1. Stop the jobs: gcloud scheduler jobs pause waterflow-jobs-hourly --location=$REGION.

  2. Create the new Supabase project and copy its session pooler address as in step 1.

  3. In Cloud Shell, with the dump’s name from gcloud storage ls gs://$BUCKET:

    mkdir -p ~/restore && cd ~/restore
    export DUMP=waterflow-20261021T190000Z.dump
    gcloud storage cp gs://$BUCKET/$DUMP .
    read -rs NEW_DB_URL && export NEW_DB_URL
    

    Paste the new address, press Enter, then:

    docker run --rm -i -v "$PWD:/w" -w /w -e NEW_DB_URL -e DUMP postgres:17 bash -s <<'EOF'
    set -eo pipefail
    psql -v ON_ERROR_STOP=1 --command='alter default privileges revoke execute on functions from public' "$NEW_DB_URL"
    psql -v ON_ERROR_STOP=1 "$NEW_DB_URL" <<< "select format('create role %I nologin', r) from unnest(array['app_user', 'account_purger', 'fx_updater']) r where not exists (select 1 from pg_roles where rolname = r) \gexec"
    pg_restore --list "$DUMP" | grep -v -E ' (SCHEMA|COMMENT) - (SCHEMA )?public ' > restore.list
    pg_restore --no-owner --single-transaction --exit-on-error --use-list=restore.list --dbname="$NEW_DB_URL" "$DUMP"
    EOF
    
  4. Point the server at the new database and restart it:

    printf '%s' "$NEW_DB_URL" | gcloud secrets versions add waterflow-admin-db-url --data-file=-
    unset NEW_DB_URL
    cd ~/waterflow && gcloud run deploy waterflow --image=$IMAGE:$(cat VERSION) --region=$REGION
    
  5. Purge again the accounts deleted for good since that backup, then start the jobs again:

    gcloud run jobs execute waterflow-jobs --region=$REGION --wait --args=-m,app.admin,repurge
    gcloud scheduler jobs resume waterflow-jobs-hourly --location=$REGION
    

Once you’ve checked everything is there, you can delete the old Supabase project. More on what a restore brings back: Backups and restore.

Cost

Approximate, for one household, at Google’s and Supabase’s prices in October 2026:

Part Cost
Cloud Run (server and jobs) Within the monthly free tier: the server is billed only while it answers, the jobs for a few seconds an hour
Cloud Build Within the free build minutes (a few builds a month)
Cloud Scheduler Free (3 jobs per billing account)
Secret Manager Free (2 secrets)
Artifact Registry About $0.10 a month above the free 0.5 GB, for three images
Cloud Storage A few cents a month
Supabase Free plan

Keeping the server awake (--min-instances=1) or giving it CPU between requests would cost several dollars to tens of dollars a month: neither is needed. Set a budget alert in Billing to be told if anything changes.

Limits

  • The first visit after a quiet spell waits a few seconds while the server starts.
  • Sign-in limits per address are weaker here. They live in the server’s memory, so they start over each time it wakes up, and behind Google’s front end the server trusts a forwarded address that a client can set. The limit per account (10 tries in 15 minutes) still holds: two-step verification for the admin is what really protects the server.
  • The daily jobs run within an hour after their time on Server admin, not on the minute. A failed job runs again the next hour.
  • The backups need pg_dump 17. Supabase projects run PostgreSQL 17 today; if yours moves to a newer major version, backups fail (Server admin shows it) until a Waterflow release ships the newer pg_dump.
  • No one-click updates: the updater helper is for Docker. Updates are the five lines above.

Edit this page · Report a problem