web / hosting
I have hosted my database-backed web apps on two PaaS providers, one after the other. Virtual machines run 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"}. Each stack used 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 sockeye, then called cibot, to virtual machines on Ubicloud.
Its cost was almost all runners running test suites. A PaaS bills a premium for managed convenience. Its scaling model also 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 sockeye runner box owns its NVMe disk and registers with the sockeye server over HTTP.
The premium has a structure. A PaaS app is a container in the platform's Kubernetes on a cloud's VMs: Heroku has run on EC2 since 2007, and Render runs its own Kubernetes clusters on EC2. Two margins sit between the app and the metal. Ubicloud never had those layers. Its control plane runs directly on bare metal leased from Hetzner, Leaseweb or OVH, and it is open source, so its prices sit near the metal provider's. cmd / uni takes the same argument one step further: it removes the operating system from the guest.
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 one run on a fresh VM completes the 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/sockeye ./cmd/soc
scp /tmp/sockeye sockeye-runners:/tmp/sockeye
I use no Docker and no registry. See unix / ssh for the host
aliases that make scp sockeye-runners: work.
A pet needs a copy off the box. cmd / r2 writes database snapshots and git bundles to object storage, so a lost VM costs me a reprovision rather than the data.
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. sockeye does not need one. The runners reach the server at its public HTTPS URL, through Caddy, the same way the dashboard and the git transport do. A port would name one blue/green slot, and a deploy stops that slot. The server's own ports listen on loopback only.
I lock the provider firewall to least privilege: the server takes SSH and the two web ports, and the runners take SSH only. Nothing internal is reachable from the internet.
SQLite
sockeye's database is one SQLite file on the server's disk, so there
is no database server to patch or connect to. A cron job takes the
snapshot with sqlite3's .backup, and a weekly job restores a
snapshot to prove it works. The throwaway test databases
stay in a local Postgres on each runner box, where speed matters and
durability does not.