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
- 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.
- 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. - Put your password in place of
[YOUR-PASSWORD](brackets included) and add?sslmode=requireat 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.1keeps 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=offandUPDATE_CHECK=off, and reference the secretwaterflow-admin-db-urlas the environment variableADMIN_DATABASE_URL. - Volume mounts: add the two volumes below and mount them.
Under Volumes:
- A Secret volume:
waterflow-db-logins, pathdb_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:
-
Prove you own the domain to Google, once per domain:
gcloud domains verify example.comIt 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.
-
Map it to the service:
gcloud beta run domain-mappings create --service=waterflow --domain=money.example.com --region=$REGION -
In Cloudflare, DNS, add a CNAME record: name
money, targetghs.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, allowpki.googandletsencrypt.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:
-
Stop the jobs:
gcloud scheduler jobs pause waterflow-jobs-hourly --location=$REGION. -
Create the new Supabase project and copy its session pooler address as in step 1.
-
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_URLPaste 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 -
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 -
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_dump17. 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 newerpg_dump. - No one-click updates: the updater helper is for Docker. Updates are the five lines above.