web / hosting

I host database-backed web applications on Render and on virtual machines.

Render

I run web services on Render with Crunchy Bridge Postgres:

Crunchy Bridge provides managed Postgres for transactions and job queues. I deploy with a small Go CLI: cmd / deploy.

Deploy client

cmd / deploy calls the Render API through an internal HTTP client:

type Client struct {
	apiKey     string
	base       string
	httpClient *http.Client
}

func (c *Client) LiveGitCommit(ctx context.Context, serviceID string) (string, error) {
	path := fmt.Sprintf("/services/%s/deploys?limit=5", serviceID)
	var deploys []deployListItem
	if err := c.get(ctx, path, &deploys); err != nil {
		return "", err
	}
	for _, d := range deploys {
		if d.Deploy.Status == "live" {
			id := d.Deploy.Commit.ID
			if len(id) > 9 {
				id = id[:9]
			}
			return id, nil
		}
	}
	return "", fmt.Errorf("no live deploy for %s", serviceID)
}

func (c *Client) Deploy(ctx context.Context, serviceID, commitID string) (string, error) {
	path := fmt.Sprintf("/services/%s/deploys", serviceID)
	body := map[string]string{"commitId": commitID}
	var d deploy
	if err := c.post(ctx, path, body, &d); err != nil {
		return "", err
	}
	return d.ID, nil
}

WaitForDeploy polls the deploy status until it reaches live or fails:

func (c *Client) WaitForDeploy(ctx context.Context, serviceID, deployID string) error {
	path := fmt.Sprintf("/services/%s/deploys/%s", serviceID, deployID)
	deadline := time.Now().Add(30 * time.Minute)
	for {
		var d deploy
		if err := c.get(ctx, path, &d); err != nil {
			return err
		}
		switch d.Status {
		case "live":
			return nil
		case "build_failed", "update_failed", "pre_deploy_failed", "deactivated", "canceled":
			return fmt.Errorf("deploy %s ended in status %q", deployID, d.Status)
		}
		if time.Now().After(deadline) {
			return fmt.Errorf("timeout waiting for deploy %s", deployID)
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(pollInterval):
		}
	}
}

A failed migration returns pre_deploy_failed, so cmd/deploy stops before deploying web services. API requests retry through the backoff helper.

Virtual machines

sockeye runs on virtual machines on Ubicloud.

Test suites account for most resource costs. A PaaS charges a premium for managed servers and restricts horizontal scaling for instances with persistent disks. Ubicloud provides twice the CPU and RAM at lower cost. Each runner owns an NVMe disk for local caches and registers with the server over HTTP.

Provisioning

I provision long-lived virtual machines with a shell script. The script installs the toolchain and local Postgres for tests.

The application compiles to a static Go binary on my laptop:

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o /tmp/sockeye ./cmd/soc
scp /tmp/sockeye sockeye-runners:/tmp/sockeye

cmd / r2 writes database snapshots and git bundles to object storage daily.

Process management and networking

systemd manages two web processes, a jobs process, and the runners. I inspect service logs with journalctl.

The server and the runners share one private subnet. Runners connect to the server's private address with its public HTTPS name, so a runner still checks the certificate that Caddy serves (see apps / sockeye). The application server listens on loopback. The cloud firewall accepts SSH from anywhere, web ports only from Cloudflare, and other service ports only from the private subnet.

Test databases run in local Postgres on each runner box.

← All articles