house handbook · v20260906-010049

Welcome to the house

Everything you need to run your own things on this box — from your first SSH login to a domain with a real certificate, a database behind it and media served from cheap cloud storage. Written for someone who has not done this before, and worth skimming even if you have.

192.255.214.140 2 vCPU 3.82 GiB RAM 58.8 GiB disk Ubuntu 26.04 LTS

Start here

What this machine is, and the two rules.

This is a virtual private server — a whole Linux computer that lives in a data centre and answers to 192.255.214.140. It has 2 CPU cores, 3.82 GiB of memory and 58.8 GiB of disk. It is on all the time. Nothing you build here goes away when you close your laptop.

You have a full account with sudo, which means you can do anything on it, including break it. That is fine and expected — it is a shared playground, not production for anyone's job. But two rules make sharing painless:

  1. Keep your work inside /srv/<something>/ One directory per project or per domain. Do not scatter things across /opt, /var/www and your home directory — when we come back in six months, /srv should be the complete list of what exists here.
  2. Never delete or edit something you did not create If a config file or service already exists and you did not put it there, ask before changing it. Adding is always safe; replacing is what breaks other people's sites.
You genuinely cannot break this permanently. Everything on this box is either in a git repo or reproducible from a config file. The worst realistic outcome is a site being down for ten minutes while someone runs systemctl restart. Experiment freely.

Your account

Getting in, and making getting in pleasant.

First login

Open a terminal on your own machine — Terminal on macOS, or Windows Terminal / PowerShell on Windows — and type:

ssh julio@192.255.214.140

The first time, it will ask whether you trust this host's fingerprint. Type yes. Then enter the password you were given in person. You are now on the server; everything you type goes to the machine, not your laptop.

Change your password immediately

passwd

It asks for the current password, then the new one twice. Nothing appears as you type — that is deliberate, not a broken keyboard.

Stop typing your password: SSH keys

A key pair is far better than a password. The private half stays on your laptop and never moves; the public half goes on the server. Run these on your own machine, not on the server:

# Make a key, if you do not already have one
ssh-keygen -t ed25519 -C "julio@laptop"
# Press enter for the default path. Set a passphrase if you like.

# Copy the public half up to the server
ssh-copy-id julio@192.255.214.140

Now ssh julio@192.255.214.140 just lets you in. To make it even shorter, add this to ~/.ssh/config on your laptop:

Host casa
    HostName 192.255.214.140
    User julio
    ServerAliveInterval 60

After that, ssh casa is the whole command.

sudo

sudo runs one command as the administrator. You have it. It asks for your password (not a separate root one), then remembers for about fifteen minutes.

sudo systemctl restart nginx     # do one thing as admin
sudo -i                          # become root until you type `exit`
The one command to be careful with is sudo rm -rf. There is no undo and no recycle bin. Read the path twice before pressing enter — particularly if it starts with / and ends with *.

The map

Where things live, and why permissions matter.

PathWhat lives there
/srv/Your projects. One folder per site or project. This is the only place you normally create things.
/srv/mecasasu.casa/The dashboard you are reading this on. A good worked example — copy its shape.
/etc/nginx/Web server config. sites-available/ holds site files, sites-enabled/ holds symlinks to the active ones.
/etc/systemd/system/Service definitions — the files that keep your app running forever.
/etc/letsencrypt/TLS certificates. Managed by certbot; never edit by hand.
/var/log/nginx/Web access and error logs, one pair per site.
/var/www/acme/Where certbot proves domain ownership. Leave it alone.
~ (/home/julio)Your personal space. Config, scratch files, notes. Not for anything the world needs to reach.

Making your first folder

/srv is group-writable by the srv group, which you are in. So you can create directories there without sudo:

cd /srv
mkdir onehumanmind.com
cd onehumanmind.com

/srv has its setgid bit set, which is the small piece of magic that makes sharing work: anything you create inside it automatically belongs to the srv group, so both of us can edit each other's files without a permissions fight.

Reading a permission string

ls -la shows things like drwxrwsr-x root srv. Left to right:

d
It is a directory. A - here means an ordinary file.
rwx
What the owner can do: read, write, execute.
rws
What the group can do. The s instead of x is that setgid bit.
r-x
What everyone else can do: read and enter, but not write.

The two names after are the owning user and the owning group.

If you ever hit "permission denied" writing to a project directory, the usual fix is to give the directory back to the shared group rather than reaching for sudo or chmod 777: sudo chgrp -R srv . && sudo chmod -R g+w .

Claude Code

Already installed on your account. Here is how to actually use it.

Claude Code is an assistant that runs in your terminal and can read and write files, run commands and build whole projects. It is not a chat window you copy code out of — it works directly in the directory you launch it from.

Launching it

cd /srv/onehumanmind.com
claude

That is the whole thing. It opens in the current directory and everything it does is scoped there. The first run asks you to log in with your Anthropic account through the browser.

The directory you launch from matters more than anything else. Launch from /srv/myproject and it works on that project. Launch from / or your home directory and it has no idea what you are working on. Always cd first.

Useful things to know on day one

Just describe what you want
"Build me a Go web server that shows my Spotify top tracks, with nginx config and a systemd service" is a perfectly good first message. Being specific about the outcome beats being specific about the implementation.
/init
Run this once in a new project. It writes a CLAUDE.md file describing the codebase, which every future session reads automatically.
Shift+Tab
Cycles permission modes. Default asks before each edit; accept edits stops asking for file changes. Start on the default until you trust what it is doing.
Esc
Interrupts whatever it is doing right now, without losing the conversation. Press it the moment something looks wrong.
! at the start of a line
Runs a shell command directly and puts the output into the conversation. Handy for things it cannot do itself, like an interactive login.
/clear
Wipes the conversation and starts fresh in the same directory. Use it between unrelated tasks — a long conversation about one thing makes it worse at the next thing.
claude --continue
Resumes your last session in this directory, including everything it remembered.

It can use sudo, but ask yourself first

Because your account has sudo, Claude Code can install packages, write nginx configs and restart services when you ask it to. That is genuinely useful. It will show you each command and wait for approval — read those prompts rather than reflexively accepting, especially anything touching /etc or another project's directory.

A good first session

cd /srv
mkdir myfirstsite && cd myfirstsite
claude

Then type something like:

Set up a small Go web server here that serves a page on port 8082.
Add a justfile with dev/build/deploy tasks, a systemd unit, and an
nginx config following the pattern in /srv/mecasasu.casa. Do not
enable the nginx site yet — I want to look at it first.

Git & GitHub

So your work exists in more than one place.

Git tracks the history of a folder. GitHub is a website that stores a copy of that history. They are different things, and you want both: git so you can undo, GitHub so a dead disk is an inconvenience rather than a catastrophe.

One-time setup

git config --global user.name  "Julio"
git config --global user.email "you@example.com"
git config --global init.defaultBranch main
# Only ever fast-forward or explicitly merge on pull — never silently rebase
git config --global pull.ff only

Connecting your GitHub account

The gh command-line tool is already installed. Log in once and both gh and git are authenticated from then on:

gh auth login

Answer the prompts:

  1. GitHub.com — not an enterprise server.
  2. HTTPS — simpler than SSH, and gh handles the credentials for you.
  3. Authenticate Git with your GitHub credentials? Yes. This is the step that makes git push work without a password.
  4. Login with a web browser. It shows an eight-character code. Open the URL on your laptop, paste the code, approve.
gh auth status   # confirm it worked

Putting a project on GitHub

cd /srv/onehumanmind.com

git init
git add -A
git commit -m "first commit"

# Creates the repo on GitHub, sets the remote and pushes, all at once
gh repo create onehumanmind.com --private --source=. --remote=origin --push

Swap --private for --public if you want it visible. After that first push, the everyday loop is three commands:

git add -A
git commit -m "what changed and why"
git push
Never commit secrets. Database passwords, API keys and cloud credentials do not belong in git — a private repo is not a secret store, and history is forever. Keep them in a .env file, add .env to .gitignore, and commit a .env.example with the keys but no values.

A .gitignore worth starting from

bin/
*.env
.env
.env.local
*.log
.DS_Store
node_modules/
tmp/

just

One file per repo that remembers every command so you do not have to.

Every project ends up with a handful of long commands: the one that builds it, the one that deploys it, the one that tails the log with exactly the right flags. just puts them in a file called a justfile and gives each one a short name.

Type just on its own in any project and it lists what is available:

cd /srv/mecasasu.casa
just
Available recipes:
    dev           # Run a local dev server on :8081 with live reload
    build         # Compile a static, stripped binary with the version stamped in
    deploy        # Build, install, restart, and confirm the site came back
    logs          # Follow the service log
    health        # Is it alive, and which build is running?
    ...

Then run one by name:

just dev
just deploy
just logs

Anatomy of a justfile

# Variables go at the top
port := "8082"

# The first recipe is the default — running plain `just` runs this one.
# The comment directly above a recipe is its description in the list.

# Show this task list
default:
    @just --list

# Run the app locally
dev:
    go run ./cmd/server -addr 127.0.0.1:{{port}}

# Build, then restart the live service
deploy: build
    sudo install -m 755 bin/server /srv/myapp/bin/server
    sudo systemctl restart myapp

# Compile the binary
build:
    go build -o bin/server ./cmd/server
Indentation must be consistent
Every line of a recipe body is indented. Spaces or a tab, but pick one and stick to it inside a recipe.
deploy: build
Anything after the colon is a dependency — it runs first. So just deploy always builds before it installs.
@ before a line
Runs it without printing the command itself. Use it for echo so your output stays readable.
Each line is its own shell
A cd on one line does not affect the next. If you need several commands to share a directory, join them with && on one line.
Recipes run from the justfile's directory
So just deploy behaves the same whether you are at the repo root or three folders deep inside it.
Put a justfile in every repo, even trivial ones. The value is not saving keystrokes — it is that six months from now, just tells you how to run a project you have completely forgotten. Ask Claude Code to write one; it knows the conventions used here.

Go projects

From an empty folder to a running web service.

Go is the right default on this box. It compiles to a single file with no dependencies — no runtime to install, no virtualenv, no node_modules. Deploying is copying one file. A small Go web service idles at around 15 MiB of memory, which matters a lot when the whole machine has 3.82 GiB.

A new project, start to finish

cd /srv
mkdir onehumanmind.com && cd onehumanmind.com

# A module name. Using the domain is a convention, not a requirement.
go mod init onehumanmind.com

mkdir -p cmd/server

Then cmd/server/main.go:

package main

import (
    "flag"
    "log"
    "net/http"
)

func main() {
    addr := flag.String("addr", "127.0.0.1:8082", "listen address")
    flag.Parse()

    mux := http.NewServeMux()

    mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        w.Write([]byte("<h1>one human mind</h1>"))
    })

    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("ok"))
    })

    log.Printf("listening on %s", *addr)
    log.Fatal(http.ListenAndServe(*addr, mux))
}

Run it:

go run ./cmd/server

In another terminal, curl http://127.0.0.1:8082/. That is a working web server. Everything after this — nginx, TLS, a domain — is about letting the rest of the world reach it.

Two rules that will save you

Always listen on 127.0.0.1, never 0.0.0.0
127.0.0.1:8082 means only this machine can connect. nginx sits in front and handles TLS, rate limits and logging. Binding to 0.0.0.0 exposes your app raw to the whole internet on a plain HTTP port, bypassing all of that.
Pick a port nobody else is using
Check first with ss -tlnp. 8080 is the dashboard. Take 8082, 8083, 8084… and write your choice into your justfile and nginx config so it is never a mystery.

Ports currently spoken for

PortUsed byReachable from
22SSHthe internet
80nginx (redirects to 443)the internet
443nginx (TLS)the internet
5432PostgreSQLthis box only
8080mecasasu.casa dashboardthis box only
8082+yours — take your pickthis box only

Adding a dependency

Go's standard library covers a great deal, so try it first. When you do need something:

go get github.com/jackc/pgx/v5     # postgres driver, for example
go mod tidy                        # prune anything unused
Read /srv/mecasasu.casa as a worked example. It is a complete, working Go service with an embedded frontend, live streaming, a justfile, a hardened systemd unit and an nginx config — all with comments explaining why each piece is the way it is. Copying its structure will not steer you wrong.

How nginx works

The doorman. Every request from the internet goes through it first.

Your Go app listens on 127.0.0.1:8082, which nothing outside this machine can reach. nginx listens on the public ports 80 and 443, works out which site a request is for, and passes it along. That indirection is what lets one server with one IP address host any number of different domains.

The path of a request

Browserasks for
onehumanmind.com
DNSanswers with
192.255.214.140
nginx :443terminates TLS,
reads the Host header
Your app127.0.0.1:8082
plain HTTP

The important step is the third one. The browser sends a Host: header saying which domain it wanted. nginx compares that against the server_name of every site it knows about and hands the request to whichever one matches. Ten domains can point at this same IP and each gets its own site, because they differ only by that header.

Where the config lives

/etc/nginx/sites-available/
One file per site. A file here is written but not live.
/etc/nginx/sites-enabled/
Symlinks to the files in sites-available that are actually switched on. To disable a site, delete the symlink — the config itself stays.
/etc/nginx/snippets/
Shared fragments you include from a site file, so common settings live in one place. See below.
/etc/nginx/conf.d/
Global settings that apply to every site: compression, timeouts, upload limits.

Reading a server block

server {
    listen      443 ssl;              # which port, and that it is TLS
    http2       on;
    server_name onehumanmind.com;     # which Host header this block claims

    ssl_certificate     /etc/letsencrypt/live/onehumanmind.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/onehumanmind.com/privkey.pem;
    include snippets/tls.conf;                # cipher settings
    include snippets/security-headers.conf;   # sensible browser headers

    location / {                              # which URLs this rule handles
        proxy_pass http://127.0.0.1:8082;     # where to send them
        include snippets/proxy-app.conf;      # pass along the real client info
    }
}

location blocks match on URL path, most specific first. location /api/ catches anything starting with /api/; location / catches everything left over. You can point different paths at entirely different apps.

The shared snippets on this box

IncludeWhat it does
snippets/tls.confModern cipher suites and session resumption. Put it in every 443 block, after the certificate lines.
snippets/proxy-app.confPasses the real client IP, hostname and scheme to your app, and enables WebSockets. Put it in every proxy_pass location.
snippets/security-headers.confA default header set for an app that sends none of its own.
snippets/hsts.confHSTS only — use this instead when your app already sets its own headers.

Turning a new site on

There is a fully commented starting point at /srv/mecasasu.casa/deploy/nginx/TEMPLATE-site.conf. Copy it, change the domain and port, then:

sudo cp TEMPLATE-site.conf /etc/nginx/sites-available/onehumanmind.com
sudo nano /etc/nginx/sites-available/onehumanmind.com   # edit domain + port

# Switch it on
sudo ln -s /etc/nginx/sites-available/onehumanmind.com \
           /etc/nginx/sites-enabled/onehumanmind.com

# ALWAYS test before reloading
sudo nginx -t
sudo systemctl reload nginx
nginx -t then reload, every single time. -t checks the config without touching the running server. reload swaps in the new config with zero dropped requests. If you skip the test and the config is broken, reload refuses and the old config keeps serving — but restart would take every site on the box down. Prefer reload.

When something is wrong

sudo nginx -t                              # syntax errors, with line numbers
sudo tail -50 /var/log/nginx/error.log     # why a request failed
sudo systemctl status nginx                # is it even running
ss -tlnp | grep 8082                       # is YOUR app actually listening

A 502 Bad Gateway almost always means nginx is fine and your app is not running, or is listening on a different port than the config says.

Domains & DNS

Pointing a name you own at this machine.

DNS is a phone book. A domain name is useless on its own — it has to resolve to an IP address. You tell it to by adding records at whoever you bought the domain from (Namecheap, Cloudflare, Porkbun, GoDaddy — the interface differs, the concepts do not).

The record types you actually need

TypePoints toUse it for
Aan IPv4 addressAlmost everything. This is the one you want.
AAAAan IPv6 addressOnly if the server has IPv6. Skip it here.
CNAMEanother nameAliasing one subdomain to another. Cannot be used on the root domain.
TXTarbitrary textProving ownership to Google, email providers, and so on.
MXa mail serverEmail. Nothing to do with websites — leave existing ones alone.

Pointing a domain here

This is exactly what was done for mecasasu.casa. In your registrar's DNS panel, add two A records:

TypeHost / NameValueTTL
A@192.255.214.140Automatic
Awww192.255.214.140Automatic
@ means the root domain itself
It is shorthand for onehumanmind.com with nothing in front. Some panels want @, some want a blank field, some want the full domain typed out. All three mean the same thing.
www is just a subdomain
There is nothing special about it. It needs its own record, which is why you add two. Our nginx config then redirects www to the bare domain so there is one canonical URL.
TTL is a caching hint
How long other servers may remember the answer. Leave it on automatic. If you plan to move a domain soon, drop it to 5 minutes a day beforehand so the change propagates quickly.

Checking it worked

DNS changes take anywhere from a minute to a few hours to spread. From this box:

getent ahostsv4 onehumanmind.com     # what this machine resolves it to
dig +short onehumanmind.com          # same, if dnsutils is installed
curl -sI http://onehumanmind.com/    # does it actually reach us

When it returns 192.255.214.140, DNS is done. If your browser still shows the old site, that is your local cache — try a private window before assuming anything is broken.

Watch for Cloudflare's orange cloud. If your domain is on Cloudflare and the record shows an orange cloud icon, traffic is being proxied through them and dig will return their IP, not ours. That is fine and often desirable, but certbot's HTTP validation and your nginx access logs both behave differently. If you are just getting started, click the cloud to make it grey (DNS only).

What happens to a domain we have not configured

nginx has a catch-all block that answers any unrecognised Host header by closing the connection. So pointing a domain here before writing its config is harmless — it will simply not load until you add a server block for it. Nothing leaks and no other site is affected.

Subdomains forever

One domain, unlimited free addresses.

Once you own onehumanmind.com, every subdomain of it is yours at no extra cost. There is nothing to buy and nobody to ask. api.onehumanmind.com, blog.onehumanmind.com, staging.api.onehumanmind.com — all free, all instant.

Adding one

  1. Add an A record Host api, value 192.255.214.140. That is it — the panel fills in the rest of the domain for you.
  2. Add an nginx server block Copy the template, set server_name api.onehumanmind.com; and point it at whatever port that app runs on.
  3. Get a certificate sudo certbot certonly --webroot -w /var/www/acme -d api.onehumanmind.com
  4. Reload sudo nginx -t && sudo systemctl reload nginx

Four steps, about two minutes, repeatable as many times as you like.

The wildcard shortcut

Tired of adding a record per subdomain? One wildcard record covers every subdomain that does not have its own:

TypeHostValueCovers
A*192.255.214.140anything.onehumanmind.com

Now DNS is solved permanently and adding a subdomain is purely an nginx job. The catch: a wildcard TLS certificate (*.onehumanmind.com) cannot be issued over HTTP validation — Let's Encrypt requires DNS validation, which means certbot needs an API token for your DNS provider. Worth setting up if you expect many subdomains; overkill for three.

Several domains on one certificate

Simpler than wildcards, and usually enough. One certificate can cover up to 100 names:

sudo certbot certonly --webroot -w /var/www/acme \
  -d onehumanmind.com \
  -d www.onehumanmind.com \
  -d api.onehumanmind.com \
  -d blog.onehumanmind.com
A useful convention: one subdomain per project, named after what it does. api. for the backend, staging. for the version you are about to break, s. for static files. Since they are free, use them instead of cramming everything under one domain with /paths/.

Certificates

The padlock. Free, automatic, and there is no reason to skip it.

Let's Encrypt issues TLS certificates for free. certbot is the tool that asks for them, proves you control the domain and renews them before they expire. Both are already installed and running here.

Getting a certificate

sudo certbot certonly --webroot -w /var/www/acme \
  -d onehumanmind.com -d www.onehumanmind.com

The --webroot method works like this: certbot writes a random file into /var/www/acme/.well-known/acme-challenge/, Let's Encrypt fetches it over plain HTTP from your domain, and the fact that it appears proves you control the domain. Every site on this box already has a location block serving that path, so it just works.

Order of operations matters. nginx will not start a listen 443 block whose certificate file does not exist yet. So: put the site up on plain HTTP first, get the certificate, then add the HTTPS block. The template file is laid out in exactly that order.

Renewal

Certificates last 90 days and renew automatically at 60 via a systemd timer. You do not need to do anything. To confirm the machinery works without spending a real issuance:

sudo certbot certificates      # what exists and when it expires
sudo certbot renew --dry-run   # rehearse a renewal
systemctl list-timers certbot* # when it will next run
Let's Encrypt rate-limits real issuances — roughly 5 duplicate certificates per week for the same set of names. If you are experimenting, add --dry-run or --staging, or you will lock yourself out of issuing for that domain for days.

Keeping it running

Because go run in a terminal dies when you close the terminal.

systemd is the part of Linux that starts things at boot and restarts them when they crash. You describe your service in one file; systemd handles the rest, forever.

A minimal unit file

Save as /etc/systemd/system/onehumanmind.service:

[Unit]
Description=onehumanmind.com
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
User=julio
WorkingDirectory=/srv/onehumanmind.com
ExecStart=/srv/onehumanmind.com/bin/server -addr 127.0.0.1:8082
Restart=always
RestartSec=2s

# Basic hardening. Costs nothing, prevents a lot.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes

# On a 4 GiB box, a leaking service should die alone rather than
# taking the database and every other site down with it.
MemoryMax=256M

[Install]
WantedBy=multi-user.target

Switching it on

sudo systemctl daemon-reload            # after ANY edit to a unit file
sudo systemctl enable --now onehumanmind # start it, and start it at boot
systemctl status onehumanmind

The commands you will use daily

CommandWhat it does
systemctl status NAMERunning? Since when? Last few log lines.
sudo systemctl restart NAMEStop and start. What you run after deploying a new binary.
sudo systemctl stop NAMEStop it. It stays stopped until you start it or reboot.
sudo systemctl disable NAMEStop it starting at boot.
journalctl -u NAME -fFollow the live log. This is where your app's output goes.
journalctl -u NAME -n 100The last 100 lines, for after the fact.
journalctl -u NAME --since "10 min ago"Logs from a window of time.
daemon-reload after every unit file edit. systemd caches unit files. Without it, your changes are simply ignored and you will spend twenty confusing minutes wondering why.

Databases

Where your data lives when it needs to outlive a restart.

The options, honestly

EngineShapeRAM hereReach for it when
PostgreSQL use this Relational~300–700 MiB Almost always. Rock solid, superb JSON support, full-text search, real transactions. Already installed and running.
MariaDBRelational~200–400 MiB You already know MySQL, or a tool you want demands it. A fine choice — just do not run both engines at once on this box.
SQLiteRelational, in a file~0 Single-app, read-heavy, no separate server. Genuinely excellent and badly underrated. The whole database is one file you can copy.
Valkey / RedisKey-value, in memory50–200 MiB Sessions, caching, rate limits, job queues. A companion to a real database, not a replacement.
MongoDBDocument~1 GiB+ Rarely worth it here. Postgres's jsonb does documents well and costs a fraction of the memory.
The recommendation is PostgreSQL, and it is already running. Use SQLite instead when a project is small, single-purpose and you would rather have one file than one more service. Do not install a second relational engine alongside Postgres — on 3.82 GiB that is the fastest way to make everything slow.

Making a database for a project

Give every project its own database and its own user:

# Pick a real password and keep it out of git
PW=$(openssl rand -base64 24)
echo "$PW"

sudo -u postgres psql <<SQL
CREATE DATABASE onehumanmind;
CREATE USER onehumanmind WITH ENCRYPTED PASSWORD '$PW';
GRANT ALL PRIVILEGES ON DATABASE onehumanmind TO onehumanmind;
\c onehumanmind
GRANT ALL ON SCHEMA public TO onehumanmind;
SQL

The last two lines matter on PostgreSQL 15 and later: the public schema is no longer writable by default, and leaving them out gives a confusing "permission denied for schema public" the first time your app tries to create a table.

Connecting

psql -U onehumanmind -d onehumanmind -h 127.0.0.1   # interactive shell
sudo -u postgres psql -l                            # list all databases

From Go, put the connection string in an environment variable:

# .env — and add .env to .gitignore
DATABASE_URL=postgres://onehumanmind:THEPASSWORD@127.0.0.1:5432/onehumanmind?sslmode=disable

sslmode=disable is correct here and only here: the connection never leaves the machine. Postgres listens on 127.0.0.1 only, and the firewall does not open 5432, so there is no network to encrypt.

go get github.com/jackc/pgx/v5/pgxpool
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
    log.Fatal(err)
}
defer pool.Close()

var name string
err = pool.QueryRow(ctx, "SELECT name FROM users WHERE id = $1", id).Scan(&name)
Always use $1 placeholders, never string concatenation. Building a query with "... WHERE id = " + input is SQL injection, and it is the single most common way small sites get taken over. The driver handles escaping correctly; you cannot.

Backups

A database with no backup is a database you are going to lose. One command, and it is a plain file you can copy anywhere:

pg_dump -U onehumanmind -h 127.0.0.1 onehumanmind | gzip > backup-$(date +%F).sql.gz

# Restoring
gunzip -c backup-2026-09-06.sql.gz | psql -U onehumanmind -h 127.0.0.1 onehumanmind

Add it to a justfile recipe, then to a nightly cron job, and push the file somewhere off this machine — cloud storage is ideal, and covered below.

Let Claude Code handle the schema. Describe what you are storing in plain language and ask it to design the tables, write the migrations and wire up the queries. It knows Postgres well. Review what it produces, but you do not need to memorise DDL syntax to get a good schema.

Logins & passkeys

If people need accounts, start with passkeys — not passwords.

A passkey is a key pair stored by the user's device and unlocked with their fingerprint, face or device PIN. The private half never leaves their phone or laptop. Your server only ever stores a public key.

That changes what a breach means:

Nothing to steal
0

Your database holds public keys. Dumping it gives an attacker nothing usable.

Phishing
Impossible

A passkey is cryptographically bound to your domain. A lookalike site cannot trigger it.

Reused passwords
N/A

There is no password to reuse, forget, leak or reset.

You maintain
Less

No reset emails, no strength rules, no hashing decisions, no breach notifications.

How the flow works

  1. Register Your server sends a random challenge. The browser asks the device to create a key pair for your domain. You store the public key against that user.
  2. Log in Your server sends a fresh challenge. The device signs it with the private key after the user's fingerprint or PIN. You verify the signature against the stored public key.
  3. Session On success, set a session cookie. From here it is ordinary web session handling.

Building it in Go

Do not implement WebAuthn yourself. The well-maintained library is:

go get github.com/go-webauthn/webauthn

You need four endpoints, and the library does the cryptography in each:

EndpointDoes
POST /register/beginCreates a challenge, stores it in the session, returns options to the browser.
POST /register/finishVerifies the response, saves the credential (public key + ID + sign counter).
POST /login/beginCreates a fresh challenge for an existing user.
POST /login/finishVerifies the signature, then sets the session cookie.

The browser side is two calls — navigator.credentials.create() and navigator.credentials.get() — and both require HTTPS. Your domain here already has it.

Session cookies, done right

http.SetCookie(w, &http.Cookie{
    Name:     "session",
    Value:    token,             # random, ≥32 bytes, from crypto/rand
    Path:     "/",
    HttpOnly: true,              # JavaScript cannot read it — blocks XSS theft
    Secure:   true,              # HTTPS only
    SameSite: http.SameSiteLaxMode, # blocks cross-site request forgery
    MaxAge:   60 * 60 * 24 * 30,
})

Always offer a fallback

Passkeys are well supported now, but someone will eventually log in from a borrowed machine or lose their phone. Offer an emailed one-time link as the recovery path — a single-use token that expires in 15 minutes. Still no passwords, and it doubles as your "I got a new phone" flow.

If you truly must store passwords — an old system, a hard requirement — use argon2id via golang.org/x/crypto/argon2, never MD5, SHA-256 or anything you wrote yourself. And rate-limit login attempts per account and per IP. But reach for this only after ruling out both options above.

Media on the cloud

When you have songs, video or big images, they do not belong on this disk.

This box has 50.8 GiB free and no easy way to grow. It is also a single machine: if the disk fails, the files are gone. Heavy media belongs in object storage — effectively infinite, replicated, and cheap to keep.

Google Cloud Storage is the recommendation here. Cloudflare R2 and Backblaze B2 are strong alternatives (notably, both have no egress fee — see the cost section, because that turns out to be the number that matters). The concepts below apply to all three.

Setting it up

  1. Make a project At console.cloud.google.com, create a project and enable billing. New accounts get a substantial free trial credit, and the always-free tier covers 5 GB of storage indefinitely.
  2. Create a bucket Bucket names are globally unique across all of Google, so prefix with your domain: onehumanmind-media. Pick a single region close to your listeners (us-central1), not multi-region — multi-region costs about 30% more and you do not need it.
  3. Turn on uniform bucket-level access This is a checkbox at creation time. It disables per-object ACLs and makes IAM the single source of truth for permissions. Without it you end up with objects whose access does not match the bucket's and no clear way to audit them.
  4. Make a service account IAM → Service Accounts → Create. This is the identity your app logs in as. Download its JSON key.
  5. Grant it the narrowest role that works On the bucket, not the project.
gcloud storage buckets add-iam-policy-binding gs://onehumanmind-media \
  --member="serviceAccount:app@PROJECT.iam.gserviceaccount.com" \
  --role="roles/storage.objectAdmin"
RoleCanGive it to
storage.objectViewerRead objectsAnything that only serves files.
storage.objectCreatorWrite, but not overwrite or deleteUpload-only paths. Very safe.
storage.objectAdminRead, write, delete objectsYour main app. The usual choice.
storage.adminAll of the above, plus delete the bucketNothing. Never grant this to an app.
The JSON key is a password to your cloud account. Put it at /srv/onehumanmind.com/secrets/gcs.json with chmod 600, add secrets/ to .gitignore, and point GOOGLE_APPLICATION_CREDENTIALS at it. A key committed to a public repo is found by scanners within minutes, and the bill arrives before you notice.

What it actually costs

Prices below are approximate US-region figures for orientation — always check the current price list before making a decision on them.

WhatRoughlyNotes
Standard storage$0.020 / GB / monthThe default. Instant access, no retrieval fee.
Nearline$0.010 / GB / month30-day minimum, plus ~$0.01/GB to read. For backups.
Coldline$0.004 / GB / month90-day minimum. Archives you hope never to need.
Archive$0.0012 / GB / month365-day minimum. Cheapest to keep, dearest to touch.
Class B ops (reads)$0.0004 / 1,000A GET of one object. Cheap.
Class A ops (writes & lists)$0.005 / 1,00012× a read. Listing a bucket is a Class A op.
Egress to the internet~$0.12 / GBThis is the one that bites.

A worked example: 10,000 songs

5 MB each, so 50 GB stored, and 100,000 plays in a month.

Storage
$1.00

50 GB × $0.02. Trivial.

Read operations
$0.04

100,000 GETs. Also trivial.

Egress
$60.00

500 GB of audio out. 98% of the bill.

If you also listed
+$5.00

One bucket list per page load, 1M loads. Pure waste.

Storing data is nearly free. Moving it is not. Optimise in this order: (1) cut egress with caching and correct file sizes, (2) never list, (3) worry about storage class last. If your project is genuinely bandwidth-heavy, look hard at Cloudflare R2 — same API shape, and egress is free, which in this example would take a $61 bill down to about $1.

Never list a bucket at runtime

This is the single most important habit. Object storage is a key-value store, not a filesystem. A "folder" is a naming convention; there is no directory structure to walk cheaply.

Listing costs 12× a read, gets slower as the bucket grows, is paginated at 1,000 objects, and gives you no way to sort or filter. The fix is simple:

Wrong: on each page load, list gs://bucket/songs/ to find out what songs exist.
Right: keep a songs table in Postgres holding the title, artist, duration, size and the object path. Query the database — which is free, instant and sortable — and touch storage only to fetch the one file the user actually asked for.
CREATE TABLE songs (
    id          bigserial PRIMARY KEY,
    title       text NOT NULL,
    artist      text NOT NULL,
    duration_ms integer,
    bytes       bigint,
    -- The object's key in the bucket. This is your index INTO storage,
    -- so you never have to ask storage what it holds.
    object_path text NOT NULL UNIQUE,
    content_type text NOT NULL DEFAULT 'audio/mpeg',
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX songs_artist_idx ON songs (artist);

Write one row every time you upload one object, in the same function. The database is then always the answer to "what exists", and the bucket is only ever asked "give me this exact key".

Structuring the bucket

Keys are flat strings, so the structure is purely a naming discipline. A good one:

onehumanmind-media/
├── audio/
│   └── 2026/09/<uuid>.mp3          # date-partitioned, unique names
├── images/
│   ├── original/<uuid>.jpg
│   └── thumb/<uuid>_400.webp        # pre-render sizes, never on the fly
├── uploads/
│   └── <user-id>/<uuid>             # one prefix per user, easy to purge
└── backups/
    └── db/2026-09-06.sql.gz         # lifecycle-rule this to Coldline
Use UUIDs, not original filenames
My Song (final) (2).mp3 brings encoding problems, collisions and a hint of what else is in the bucket. Store the pretty name in Postgres and the ugly one in the bucket.
Make objects immutable
Never overwrite a key. Upload a new UUID and update the database row. This makes aggressive caching safe, because a given URL's content can never change.
Partition by date
2026/09/ keeps any one prefix small, which makes the rare occasion you do need to list survivable.
One prefix per user for anything user-uploaded
Deleting an account then becomes one prefix delete rather than a hunt.
Set Cache-Control at upload time
public, max-age=31536000, immutable on immutable objects. Every browser and CDN then stops re-fetching, and your egress bill drops accordingly.

Lifecycle rules: let Google move old data for you

cat > lifecycle.json <<'JSON'
{
  "lifecycle": {
    "rule": [
      { "action": {"type": "SetStorageClass", "storageClass": "NEARLINE"},
        "condition": {"age": 30, "matchesPrefix": ["backups/"]} },
      { "action": {"type": "SetStorageClass", "storageClass": "COLDLINE"},
        "condition": {"age": 90, "matchesPrefix": ["backups/"]} },
      { "action": {"type": "Delete"},
        "condition": {"age": 365, "matchesPrefix": ["backups/"]} }
    ]
  }
}
JSON

gcloud storage buckets update gs://onehumanmind-media --lifecycle-file=lifecycle.json

Serving files

Public files: let the browser fetch them directly

Do not proxy media through this box. Piping a 5 MB song through Go and nginx burns your CPU and your VPS bandwidth for no benefit. Store the public URL and put it straight in the <audio> or <img> tag.

Private files: signed URLs

A signed URL is a temporary link that grants access to one object for a limited time. The bucket stays private; the link does the authorising. Generating one is a local cryptographic operation — it costs nothing and makes no API call.

import "cloud.google.com/go/storage"

url, err := client.Bucket("onehumanmind-media").SignedURL(objectPath,
    &storage.SignedURLOptions{
        Method:  "GET",
        Expires: time.Now().Add(15 * time.Minute),
    })

Uploading from Go

go get cloud.google.com/go/storage
client, err := storage.NewClient(ctx)   # reads GOOGLE_APPLICATION_CREDENTIALS
if err != nil { return err }
defer client.Close()

obj := client.Bucket("onehumanmind-media").Object(objectPath)
w := obj.NewWriter(ctx)
w.ContentType  = "audio/mpeg"
// Immutable object, so let every cache keep it for a year.
w.CacheControl = "public, max-age=31536000, immutable"

if _, err := io.Copy(w, file); err != nil {
    w.Close()
    return err
}
if err := w.Close(); err != nil {   # the upload only completes on Close
    return err
}

// Then, in the same operation, record it in Postgres.
_, err = pool.Exec(ctx,
    `INSERT INTO songs (title, artist, object_path, bytes) VALUES ($1,$2,$3,$4)`,
    title, artist, objectPath, size)
Set a budget alert before you upload anything. Billing → Budgets & alerts → set a monthly cap with email at 50/90/100%. Cloud bills are usage-based, and a bug in a retry loop can run up real money overnight. Ten dollars is a fine place to start.

Limits of this box

What it comfortably does, and what will make it unhappy.

CPU
2 cores

Intel(R) Core(TM) i9-14900K. Plenty for serving; a build will use both.

Memory
3.82 GiB

The real constraint. Plus 2.00 GiB swap, which is a safety net, not extra RAM.

Disk
58.8 GiB

50.8 GiB free right now. Not expandable.

Public IP
1

192.255.214.140. Shared by every domain, which is what nginx is for.

Fits comfortably

  • A dozen or more Go services. Each idles at 15–40 MiB. This is the sweet spot.
  • PostgreSQL with real data. Millions of rows is fine on this hardware.
  • Static sites and SPAs. Essentially free — nginx serves those in its sleep.
  • Modest traffic. A well-written Go service here handles thousands of requests per second. Traffic is very unlikely to be your problem.
  • Cron jobs, bots, scrapers, small APIs. Exactly what a box like this is for.

Will hurt

  • Anything on the JVM. A single Java or Elasticsearch process wants more memory than this machine has.
  • A second database engine. Pick Postgres or MariaDB, not both.
  • Docker for everything. Fine occasionally; a full compose stack per project will eat the RAM.
  • Serving heavy media from the disk. Use cloud storage — that is the section above.
  • Large builds while the site is live. Two cores means a big go build or an npm install competes with what is serving. It recovers; it just looks alarming on the dashboard.
  • ML inference of any size. No GPU, and not enough RAM. Call an API instead.

When something feels slow

htop                          # what is using CPU and memory right now
free -h                       # is memory actually exhausted
df -h                         # is the disk full (a very common culprit)
sudo du -xh --max-depth=2 /srv | sort -rh | head -20   # what is eating the disk
journalctl -p err -n 50       # recent errors from everything
ss -tlnp                      # what is listening on which port

Or just open the dashboard — it shows all of this live, and the process table is sorted by CPU.

A full disk breaks everything at once — Postgres stops accepting writes, nginx cannot log, and the errors it produces do not obviously say "disk". Check df -h first whenever the box behaves strangely. Logs and old build artefacts are the usual cause.

Cheat sheet

The commands worth keeping in a tab.

Getting around

ssh julio@192.255.214.140    # log in
cd /srv                         # where projects live (alias: srv)
ll                              # detailed listing
nano FILE                       # simple editor: ctrl-o saves, ctrl-x exits
sudo -i                         # become root; `exit` to come back

Starting something new

cd /srv && mkdir myproject && cd myproject
go mod init myproject
claude                          # and describe what you want built

Services

systemctl status NAME
sudo systemctl restart NAME
journalctl -u NAME -f           # follow the log
sudo systemctl daemon-reload    # after editing a unit file

nginx

sudo nginx -t                             # test config — always do this first
sudo systemctl reload nginx               # apply, with no dropped requests
ls /etc/nginx/sites-enabled/              # which sites are live
sudo tail -f /var/log/nginx/error.log

Certificates

sudo certbot certificates
sudo certbot certonly --webroot -w /var/www/acme -d DOMAIN -d www.DOMAIN
sudo certbot renew --dry-run

Postgres

sudo -u postgres psql -l                   # list databases
psql -U USER -d DB -h 127.0.0.1           # connect
pg_dump -U USER -h 127.0.0.1 DB | gzip > backup.sql.gz

Git

git status
git add -A && git commit -m "message" && git push
git log --oneline -10
git diff                                  # what changed since the last commit
gh repo create NAME --private --source=. --push

Health

htop            free -h         df -h
ss -tlnp        uptime          journalctl -p err -n 50
When in doubt, ask Claude Code. Launch it in the directory you are working in and describe the problem in plain language — including pasting an error you do not understand. It can read the configs on this box, see the logs, and fix things directly. You do not need to memorise any of this page; it is here so you know what is possible.

Back to the dashboard