- Go 59.6%
- Vue 12.1%
- TypeScript 11%
- Nix 8.8%
- SCSS 8%
- Other 0.5%
| auth | ||
| config | ||
| db | ||
| web | ||
| .gitignore | ||
| api_auth.go | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| handlers.go | ||
| main.go | ||
| README.md | ||
sovr.cloud
Tools that make self-hosting ordinary.
sovr.cloud is a subscription service for people who want to run their own infrastructure without depending on a major cloud provider, and without needing an advanced computer science degree to get the basics right.
The premise: many of the things that make self-hosting genuinely hard are small in number, well understood, and tedious to assemble correctly. Each one is a project where the hard part is not the software — it is knowing which software, which configuration, and which of the dozen subtly wrong ways to wire it together. sovr.cloud packages those pieces so a person can point at a box in their closet and get working services out of it.
The tools
These are the problems the service is being built around. Each is a case where the correct solution is known but disproportionately difficult for an individual to discover, implement, and maintain alone.
-
Tunnels that survive ISP port blocks. Many residential connections cannot accept inbound traffic on the ports that matter, or are behind CGNAT entirely. A tunnel gives a self-hosted service a stable public path in without a business circuit or a static IP.
-
Working TLS everywhere, even inside the LAN. Public certificate authorities cannot issue for internal-only names, and a certificate issued for the public name is not valid for an internal address. The usual answer — a wildcard certificate deployed alongside split-horizon DNS — is a set of moving parts that must agree with each other exactly. When they do, a name like
service.example.comresolves to the LAN address inside the network and the public address outside it, and the same TLS certificate is valid in both places. No browser warnings, no exceptions, no "just click through it". -
The joining of the two. Internal names that work from outside the network, external names that keep working from inside it, and certificates that are trusted in both directions.
These are the hard edges. Anything that can be made routine should be routine.
What exists today
The site is a Go HTTP server with a PostgreSQL-backed account system. The product itself is still mostly skeleton, but the deployment model and the pieces below are real and exercised end to end:
- Environment-only configuration.
config.Parsereads the process environment into aConfig. Missing or malformed values are reported together at startup, so one restart lists everything a deployment got wrong. - Schema ownership. The binary embeds its goose migrations and applies them on startup, so deploying a new build also brings the database to the matching schema.
- Compile-checked queries. All SQL is built with go-jet from generated
table/model packages in
db/gen/sovr. Handwritten SQL strings are not used by application code. Changing a column breaks the query at compile time. - Users and sessions. A
user_table stores a username, email, and either a bcrypt password hash, a WebAuthn user handle, or both. Sign-up, sign-in, account display, and sign-out all run through the SPA and a JSON API. - Passkeys. Registration and assertion use WebAuthn; credentials live in
webauthn_credentialwith the signature counter and backup flags. A user can sign up with a passkey instead of a password, and sign in with it later. - A compiled frontend bundle.
web/is a Vue 3 + TypeScript single page application built by Vite from SCSS. The build output is embedded into the Go binary and served from/, with hashed assets cached immutably. - NixOS deployment. The flake builds the binary and exports a hardened systemd unit.
Sessions currently live in the process's memory, so restarting the service signs everyone out. A durable session store is a later concern.
Layout
| Path | Purpose |
|---|---|
main.go |
Server startup, shutdown, CLI commands, embedded bundle |
handlers.go |
Routes, SPA serving, session JSON API |
api_auth.go |
Signup, password login, passkey ceremonies, logout |
config/config.go |
Environment parsing and validation |
auth/password.go |
bcrypt hashing and verification |
auth/passkey.go |
WebAuthn relying party and credential conversion |
db/db.go |
Connection pool, embedded goose migrations, query helpers |
db/migrations/*.sql |
The schema, owned by this application |
db/query/public/*.go |
Typed queries written against generated Jet code |
db/gen/sovr/ |
Generated Jet models and table builders (committed) |
db/jet/main.go |
Regenerates db/gen/sovr from a live database |
web/ |
Vue 3 + TypeScript SPA, Vite config, SCSS |
web/src/views/ |
Home, signup, sign-in, and account pages |
web/dist/ |
Built bundle; embedded into the Go binary, not committed |
flake.nix |
Package build, dev shell, NixOS module |
Configuration
Everything is an environment variable. There is no config file.
| Variable | Default | Meaning |
|---|---|---|
HOST |
127.0.0.1 |
Address to bind |
PORT |
8080 |
TCP port to bind |
POSTGRES_DSN |
(required) | PostgreSQL connection string |
COOKIE_SECURE |
true |
Mark the session cookie Secure |
WEBAUTHN_RP_ID |
localhost |
Relying-party ID; production uses sovr.cloud |
WEBAUTHN_RP_NAME |
sovr.cloud |
Name shown by the passkey manager |
WEBAUTHN_RP_ORIGINS |
http://localhost:5173,http://localhost:8080,… |
Comma-separated origins allowed to run ceremonies |
The WebAuthn defaults are for local development. A production deployment sets
WEBAUTHN_RP_ID=sovr.cloud and
WEBAUTHN_RP_ORIGINS=https://www.sovr.cloud; the NixOS module does this by
default. Passkeys require a secure context: HTTPS, or localhost during
development. A page served from 127.0.0.1 is not a valid origin for an RP ID
of localhost.
The default bind address is loopback on purpose: TLS and public exposure are
handled by a reverse proxy in front of the process, not by the process itself.
Set COOKIE_SECURE=false only for local development over plain HTTP.
A Unix-domain-socket database deployment looks like:
POSTGRES_DSN='postgres:///sovr?host=/run/postgresql&sslmode=disable'
Add whatever user and password the socket's authentication method requires.
Running it
With Nix:
nix run
Or build the binary and run it directly:
nix build
POSTGRES_DSN='postgres:///sovr_dev?host=/run/postgresql&sslmode=disable' \
COOKIE_SECURE=false ./result/bin/sovr
It applies migrations, then listens on 127.0.0.1:8080:
curl http://127.0.0.1:8080/ # the embedded Vue SPA
curl http://127.0.0.1:8080/signup # sign up with a password or passkey
curl http://127.0.0.1:8080/login # sign in
nix build compiles the SPA first and embeds the result, so the Nix-built
binary is self-contained. A plain go build embeds whatever is currently in
web/dist; build that directory first, or the root path answers 503 while
the rest of the server still runs.
The sign-up page creates accounts. The CLI remains as a bootstrap path for an operator who needs a password account without the browser:
POSTGRES_DSN='postgres:///sovr_dev?host=/run/postgresql&sslmode=disable' \
./result/bin/sovr user add -username alice -password 'correct horse battery staple'
-password is visible in the process list while the command runs, so avoid
running it on a shared host.
Development shell
nix develop
It provides Go, Node.js, pnpm, go-jet, goose, gopls, and PostgreSQL
client/server tools.
Frontend
web/ is a Vue 3 single page application in TypeScript. Vite compiles it and
SCSS compiles into CSS as part of the same build. Output goes to web/dist,
which the Go binary embeds with //go:embed.
For UI development, run Vite and the Go server side by side:
# terminal 1
nix develop
POSTGRES_DSN='postgres:///sovr_dev?host=/run/postgresql&sslmode=disable' \
COOKIE_SECURE=false go run .
# terminal 2
cd web
pnpm install
pnpm dev
Open http://localhost:5173. Vite serves the SPA with hot reload and proxies
/api to the Go process on :8080. Use localhost, not 127.0.0.1, when
testing passkeys: the default relying-party ID is localhost.
To produce the embedded bundle yourself:
cd web
pnpm install
pnpm build
The build type-checks with vue-tsc first, so a type error fails the build.
Hashed files under web/dist/assets are served with a one-year immutable
cache; index.html is served with no-cache so a new deployment is picked up
on the next load. web/dist is not committed: nix build regenerates it, and
web/dist/.gitkeep keeps the embed directory present in a fresh checkout.
Styles live in web/src/styles (_variables.scss for tokens, _mixins.scss
for shared treatments, main.scss for the page). Add new partials with
@use, not @import.
Database
The application owns its schema. Migrations live in db/migrations and are
embedded into the binary; both the server and sovr user add apply pending
migrations before doing anything else. Goose takes an advisory lock, so several
instances starting together still apply each migration once.
To write a migration, add the next numbered file:
-- +goose Up
ALTER TABLE user_ ADD COLUMN display_name TEXT;
-- +goose Down
ALTER TABLE user_ DROP COLUMN display_name;
Apply migrations manually with goose (optional — the binary does it):
GOOSE_DRIVER=postgres \
GOOSE_DBSTRING="$POSTGRES_DSN" \
goose -dir db/migrations up
Generated queries
db/gen/sovr is generated from the live schema and committed. Do not edit it
by hand.
POSTGRES_DSN='postgres:///sovr_dev?host=/run/postgresql&sslmode=disable' \
go run ./db/jet
Run that after applying a migration that changes a table, then commit the regenerated files. Application queries use the generated columns, for example:
stmt := table.User.
SELECT(table.User.AllColumns).
WHERE(table.User.Username.EQ(postgres.String(username)))
The compiler rejects a column that does not exist on table.User. db.Ex
accepts either the pool or a transaction, so the same query works in both.
Deployment
The flake exports nixosModules.default:
{
inputs.sovr.url = "git+https://github.com/…/sovr-cloud";
imports = [ inputs.sovr.nixosModules.default ];
services.sovr = {
enable = true;
port = 8080;
postgresDsn = "postgres:///sovr?host=/run/postgresql&sslmode=disable";
};
}
The service listens on loopback and expects a reverse proxy such as Caddy to
terminate TLS and forward to it. Firewall rules, DNS, and certificates belong
to that proxy layer, not to this process. The package build compiles the Vue
bundle and embeds it, so a deployed binary needs no Node.js at runtime. The
module defaults WEBAUTHN_RP_ID to sovr.cloud and
WEBAUTHN_RP_ORIGINS to https://www.sovr.cloud; change both together if the
site moves. The unit may use an external database; adjust postgresDsn, or
remove postgresql.service from its after list for your packaging if you
do.
Authentication
The SPA talks to a small JSON API:
POST /api/signup— username, email, password; creates the account and starts a session.POST /api/signup/passkey/beginand/finish— the WebAuthn registration ceremony. The user row and credential are written in one transaction after the authenticator succeeds.POST /api/login— username and password.POST /api/login/passkey/beginand/finish— the WebAuthn assertion. The username is required, so the flow is not username-less; the assertion is validated against that account's stored credentials.GET /api/session—{authenticated, username}.POST /api/logout— destroys the session.
/account shows the signed-in username and a sign-out button. Unknown or
deleted users are signed out. Authentication rotates the session token.
Passkeys store the credential ID, public key, attestation metadata, signature
counter, and backup flags in webauthn_credential. The signature counter and
flags are updated after each successful assertion; a clone warning is
persisted. A username may have a password, a passkey, or both.
Every mutating API call requires Content-Type: application/json. The session
cookie is HttpOnly, SameSite=Lax, and Secure by default.