Self-hosting on Oracle Cloud
Run Diffbridge on the OCI Always Free tier with Terraform and Docker Compose.
Diffbridge runs as one Docker image in two roles (api and worker) next to Postgres and Caddy. On
OCI's Always Free tier that all fits on a single Ampere A1 VM (arm64); the image is built
multi-arch, so it runs as-is.
| Piece | OCI Always Free resource | Notes |
|---|---|---|
| App + worker + Postgres + Caddy | VM.Standard.A1.Flex, 2 OCPU / 12 GB (up to 4 / 24 free) |
Docker Compose |
| Packages, snapshots, rollback data | Object Storage bucket (S3-compatible API) | 20 GB free; or keep blobs on the boot volume (STORAGE_DRIVER=fs) |
| TLS | Caddy + Let's Encrypt | automatic |
| Backups | pgbackup sidecar, nightly deploy/backup-to-object-storage.sh into a separate rapidforge-backups bucket, weekly restore test |
7 days on the VM, 35 days off-box; see Operations |
1. Build and push the image
The GitHub Actions workflow (.github/workflows/ci.yml) tests, then builds linux/arm64,linux/amd64
and pushes ghcr.io/<you>/rapidforge. To build by hand:
docker buildx build --platform linux/arm64,linux/amd64 -t ghcr.io/your-org/rapidforge:latest --push .
2. Provision with Terraform
cd deploy/terraform/oci
cp terraform.tfvars.example terraform.tfvars # fill in OCIDs, SSH key, domain
terraform init
terraform apply
Terraform creates the VCN, a public subnet (22/80/443), the Object Storage buckets (the app's, with
versioning, and one for backups, with retention rules), an S3 "customer secret key", and the A1
instance. cloud-init installs Docker, writes /opt/rapidforge/.env with generated secrets, schedules
the nightly backup and weekly restore test, and starts the stack.
- Point your domain's A record at
terraform output public_ip. Caddy issues the certificate once DNS resolves. - Back up the encryption key:
terraform output -raw encryption_key. Without it, stored org tokens can't be decrypted. - If you see Out of host capacity, A1 capacity is temporarily exhausted in that AD — set
availability_domain_indexto 1 or 2, or retry later. - Secrets are passed via instance metadata on first boot only (
ignore_changes = [metadata]). Rotate them in/opt/rapidforge/.envanddocker compose up -d.
3. Without Terraform (any VM)
git clone https://github.com/your-org/rapidforge && cd rapidforge/deploy
cp .env.example .env && $EDITOR .env
docker compose up -d
On OCI Ubuntu images, open the host firewall too:
sudo iptables -I INPUT 6 -p tcp --dport 80 -j ACCEPT && sudo iptables -I INPUT 6 -p tcp --dport 443 -j ACCEPT && sudo netfilter-persistent save.
4. Operations
- Upgrade:
docker compose pull && docker compose up -d. To do this on every push tomain, add repository secretsDEPLOY_HOST,DEPLOY_USER(defaultubuntu) andDEPLOY_SSH_KEY(a private key authorised on the VM), and optionally the variablesDEPLOY_PATH(default/opt/rapidforge) andRAPIDFORGE_URL(enables a health check after the rollout). Thedeployjob inci.ymlthen SSHes in once tests and the image build pass, pulls the image for that commit from GHCR, retags it asrapidforge:local(what the VM'sRAPIDFORGE_IMAGEpoints at) and runsdocker compose up -d. The VM must be able todocker pullfrom GHCR (log in once with a read-only token if the package is private). Migrations run automatically on start, forward-only. - More throughput:
docker compose up -d --scale worker=2. Workers coordinate through Postgres advisory locks, so each org still runs one deployment at a time. - Backups, restores, monitoring and a go-live checklist: see Operations. Without Terraform, copy
deploy/*.shto/opt/rapidforgeand add the two cron lines from there. - Logs:
docker compose logs -f api worker(structured JSON). - Health:
GET /healthzchecks the DB connection (the container's DockerHEALTHCHECK);GET /healthz?deep=1also checks file storage and the worker, for uptime monitoring.
Deploying without GitHub Actions
GitHub's free plan includes 2,000 Actions minutes a month. When they run out (or you'd rather not wait for CI), deploy from your own computer:
npm run deploy:local # type check, tests, build on the VM, restart, wait for health
npm run deploy:local -- --dry-run # show the steps and touch nothing
It deploys the committed HEAD of main. The tests run on your computer first; the image is built
on the VM itself with git archive HEAD | ssh ... docker build, so the build is native arm64 (no
emulation) and you don't need Docker on your Mac or Windows PC, only git, ssh and Node. The
same compose steps as the CI deploy job then recreate the containers, and the script waits for
/healthz?deep=1.
Settings (environment variables, all optional): DEPLOY_HOST (default
opc@diffbridge.mokahub.duckdns.org), DEPLOY_PATH (/opt/rapidforge), DEPLOY_SSH_KEY_FILE
(~/.ssh/rapidforge_deploy), RAPIDFORGE_URL.
To keep GitHub free, set an Actions budget of $0 under Settings → Billing → Budgets: when the
minutes run out, workflows stop instead of billing, and you use deploy:local until the reset.
Changing the site's address (moving to a new domain)
Do it in this order so nothing breaks, and keep the old address working until the end:
- DNS and Caddy: point the new name at the VM, add a site block for it next to the old one in
/etc/caddy/conf.d/diffbridge.caddy(copy the old block; on the shared host VM the Diffbridge site lives in that file, loaded through/etc/caddy/entry.caddy, so another project rewriting/etc/caddy/Caddyfilecannot remove it), runsudo caddy validate --config /etc/caddy/entry.caddy --adapter caddyfile, thensudo systemctl reload caddy. Both addresses now serve the app. - Add the new callback addresses, keeping the old ones: the Salesforce login app's global OAuth settings (the Dev Hub org that owns the package), and the GitHub, GitLab and Bitbucket OAuth apps used for "Continue with ...". Their paths are
/api/orgs/oauth/callbackand/api/git/oauth/<github|gitlab|bitbucket>/callback. Google and GitHub sign-in apps, if you use them, use/api/auth/oauth/<provider>/callback. - Switch the server: in
/opt/rapidforge/.envsetPUBLIC_URL,SF_CALLBACK_URL(andDOMAINif the bundled Caddy is used) to the new address, then recreateapiandworker. - Stripe: edit the webhook destination's URL (its signing secret stays the same).
- Outside the app: update
RAPIDFORGE_URL(repository variable for the uptime workflow), your uptime monitor, Search Console, and any links in emails or docs. - Later, retire the old address (redirect it, and remove its callbacks) once nothing uses it.