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:

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:

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.

← All articles