web / hosting
I have hosted my database-backed web apps three ways: a PaaS from 2009 to 2023, a second PaaS since, and virtual machines for the workload that outgrew both.
Heroku
From 2009 to 2023 I deployed to Heroku. The workflow was:
- Version code in a GitHub repository.
- Open GitHub pull requests with continuous integration checks.
- Merge into
mainto auto-deploy to staging. - Promote staging to production through a pipeline.
- Configure the production database with a high availability follower and continuous backups.
Performance and reliability were good. Its GitHub integration broke in April 2022 and took over a month to resolve, so I lost confidence in the platform. Heroku also lacked HTTP/2, and restricting its Postgres from the public internet meant a large cost increase.
Render
To compare replacements I built
croaky/webstack, a set of PaaS
prototypes. Each stack served a health-check endpoint that ran
SELECT 1 and returned {"status":"ok"}, with a lightweight router, a
SQL driver without an ORM, and a connection pool:
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"github.com/jackc/pgx/v4/pgxpool"
)
func main() {
// env
port, ok := os.LookupEnv("PORT")
if !ok {
port = "8080"
}
dbUrl, ok := os.LookupEnv("DATABASE_URL")
if !ok {
dbUrl = "postgres:///webstack_dev"
}
// db
db, err := pgxpool.Connect(context.Background(), dbUrl)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// routes
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
var col int
db.QueryRow(r.Context(), "SELECT 1").Scan(&col)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, "{\"status\":\"ok\"}")
})
// listen
log.Println("Listening at http://localhost:" + port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
I set up API checks in Checkly from Northern California and London, where my users are.
I chose Render with Crunchy Bridge Postgres, the smallest step from Heroku. What I liked about Render:
- Good customer support.
- IP access control on Postgres databases
- DDoS protection
- HTTP/2 and HTTP/3 support.
- Zero-downtime deploys via health checks
- SOC 2 certification.
Crunchy Bridge fits because I am all-in on Postgres, for transactions and for job queues. I deploy with a small Go CLI: cmd / deploy.
Virtual machines
In 2026 I moved cibot to virtual machines on Ubicloud.
cibot's cost was almost all workers running test suites. A PaaS bills a premium for managed convenience, and its scaling model fought my workload: a persistent disk keeps caches warm across runs, but the PaaS does not scale servers that have disks. I could only scale up to a bigger box, never out to more of them.
Ubicloud gave me roughly twice the CPU and RAM for about a third less than the PaaS charged. A VM per box gives warm local caches and horizontal scale together. Each cibot workers box owns its NVMe disk and registers with the cibot farmer over HTTP.
Pets, not cattle
I run a small number of long-lived VMs and provision them directly instead of baking an image. A shell script installs the toolchain and a local Postgres for tests, tuned for the box size. The script is idempotent, so re-running it on a fresh VM is the whole setup.
The application is a static Go binary. I cross-compile on my laptop and copy it up:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o /tmp/cibot ./cmd/cibot
scp /tmp/cibot cibot-workers:/tmp/cibot
No Docker and no registry. See unix / ssh for the host
aliases that make scp cibot-workers: work.
Running the processes
A PaaS starts application processes and restarts them after crashes. On
a VM, systemd does that job. I run the web process
and the background workers as units, and read their output with
journalctl.
Networking
A PaaS gives a private network between services for free. On Ubicloud I put the VMs on one private subnet, so the farmer and workers talk over private addresses with no public exposure and no egress cost.
The workers reach the farmer at its private address (its FARMER_URL is
http://10.x.x.x:1994, not the public IP), so that traffic never leaves
the subnet. Only the dashboard and the git transport are public, behind
Caddy for TLS.
I lock the provider firewall to least privilege: SSH plus the public web ports from anywhere, and everything else only from the private subnet. Nothing internal is reachable from the internet.
Managed Postgres
cibot's database runs on Ubicloud's managed Postgres, with backups and a CA I verify against. The throwaway test databases stay local on each worker box, where speed matters and durability does not.