DEV Community

Cover image for What we learned building a paid inbox you can self-host (Go + Next.js + Postgres)
Saloni G
Saloni G

Posted on

What we learned building a paid inbox you can self-host (Go + Next.js + Postgres)

We built Hotly, an inbox where people pay to send someone a note, and that person replies when they have time. It started as a product for one creator whose DMs had become unmanageable. This month we open-sourced it under Apache 2.0, so anyone can run their own.

This isn't a launch post. It's the handful of decisions that shaped the code, and why we made them. The rest of this series goes deeper on each one.

1. One person per install

The obvious design is multi-tenant: one database, many creators, a creator_id on every row. We went the other way. Each install serves exactly one person, with one Stripe account and one inbox. The creators table enforces it with a singleton column:

singleton BOOLEAN NOT NULL DEFAULT TRUE CHECK (singleton) UNIQUE
Enter fullscreen mode Exit fullscreen mode

What that bought us:

  • Money goes straight into the owner's own Stripe. There's no platform account in the middle, no Connect onboarding, no payouts to reconcile.
  • The data model stays small. No tenant scoping on every query, no "can user A see user B's notes" bugs.
  • Leaving is trivial. Your database, your media and your Stripe customers are already yours.

The cost: an agency running it for ten people runs ten installs. We think that's the right trade for something that handles personal messages and money.

2. The people writing in don't get accounts

Asking someone to sign up before they can pay to send one note kills the flow. So guests never sign up. They pay through Stripe Checkout, and once the webhook confirms the payment, the success page swaps the Checkout session for an HTTP-only session cookie. If they lose it, they request a single-use recovery link by email.

Only the owner signs in, with Google via Firebase. If you'd rather use generic OIDC, that's the piece to swap.

3. Prices live on the server

Checkout uses named tiers. The browser says "the 500-character note", never "$10", and the server ignores any amount or currency the client sends. When Stripe's webhook arrives, the server checks the amount again.

Notes are priced by length: $5 for 250 characters, $10 for 500 and $15 for 1,000 by default, all editable by the owner. A custom amount has to beat the top tier.

Pricing by length turned out to matter. It prices the owner's reading and reply time, not the message. The weekly subscription works the same way: $15 a week by default for a 2,000-character allowance, refilled each billing week.

4. Replies can be locked

A reply can carry a photo or video, free or paid to unlock. Media sits in Garage, a small S3-compatible store that only listens inside the Compose network. Every file goes through an API route that checks the session and the unlock. There are no public bucket URLs.

5. A boring stack in one Compose file

  • Go (Chi, pgx, sqlc) for the API
  • Next.js on Bun for the web app
  • Postgres 18
  • Garage for media
  • Caddy for TLS

docker compose up starts them in order, and a one-shot migrate container runs before the API.

We tried to keep the hosting story honest. This is five long-running services with volumes for data and media, so single-service platforms don't fit, but a Linux VPS does. There are no prebuilt images yet, so it builds on the box. The build peaks around 2.7 GB of RAM; idle, it sits at a few hundred MB.

6. Being upfront about AI

A good part of the code, tests and docs was written with an AI coding assistant. We review and run everything, and CI runs the Go tests and type checks on every pull request and release tag. We'd rather say that than have someone find AGENTS.md and wonder.

Does anyone pay?

We ran it with one creator, 536K followers on Instagram, who told the fans she already had. 217 people paid to write to her, across 921 conversations. One creator isn't a market, but it was enough to convince us the mechanic works.

Try it

We'd love feedback, especially on the one-person-per-install boundary and on what would stop you from running it.

Top comments (0)