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:
-
Keep your work inside
/srv/<something>/One directory per project or per domain. Do not scatter things across/opt,/var/wwwand your home directory — when we come back in six months,/srvshould be the complete list of what exists here. - 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.
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`
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.
| Path | What 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
sinstead ofxis 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.
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.
/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.mdfile 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:
- GitHub.com — not an enterprise server.
- HTTPS — simpler than SSH, and
ghhandles the credentials for you. - Authenticate Git with your GitHub credentials? Yes. This is the step that makes
git pushwork without a password. - 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
.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 deployalways builds before it installs. - @ before a line
- Runs it without printing the command itself. Use it for
echoso your output stays readable. - Each line is its own shell
- A
cdon 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 deploybehaves the same whether you are at the repo root or three folders deep inside it.
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:8082means only this machine can connect. nginx sits in front and handles TLS, rate limits and logging. Binding to0.0.0.0exposes 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
| Port | Used by | Reachable from |
|---|---|---|
| 22 | SSH | the internet |
| 80 | nginx (redirects to 443) | the internet |
| 443 | nginx (TLS) | the internet |
| 5432 | PostgreSQL | this box only |
| 8080 | mecasasu.casa dashboard | this box only |
| 8082+ | yours — take your pick | this 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
/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
onehumanmind.com
192.255.214.140
reads the Host header
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-availablethat are actually switched on. To disable a site, delete the symlink — the config itself stays. - /etc/nginx/snippets/
- Shared fragments you
includefrom 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
| Include | What it does |
|---|---|
| snippets/tls.conf | Modern cipher suites and session resumption. Put it in every 443 block, after the certificate lines. |
| snippets/proxy-app.conf | Passes the real client IP, hostname and scheme to your app, and enables WebSockets. Put it in every proxy_pass location. |
| snippets/security-headers.conf | A default header set for an app that sends none of its own. |
| snippets/hsts.conf | HSTS 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
| Type | Points to | Use it for |
|---|---|---|
| A | an IPv4 address | Almost everything. This is the one you want. |
| AAAA | an IPv6 address | Only if the server has IPv6. Skip it here. |
| CNAME | another name | Aliasing one subdomain to another. Cannot be used on the root domain. |
| TXT | arbitrary text | Proving ownership to Google, email providers, and so on. |
| MX | a mail server | Email. 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:
| Type | Host / Name | Value | TTL |
|---|---|---|---|
| A | @ | 192.255.214.140 | Automatic |
| A | www | 192.255.214.140 | Automatic |
- @ means the root domain itself
- It is shorthand for
onehumanmind.comwith 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
wwwto 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.
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
-
Add an A record
Host
api, value192.255.214.140. That is it — the panel fills in the rest of the domain for you. -
Add an nginx server block
Copy the template, set
server_name api.onehumanmind.com;and point it at whatever port that app runs on. -
Get a certificate
sudo certbot certonly --webroot -w /var/www/acme -d api.onehumanmind.com -
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:
| Type | Host | Value | Covers |
|---|---|---|---|
| A | * | 192.255.214.140 | anything.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
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.
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
--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
| Command | What it does |
|---|---|
| systemctl status NAME | Running? Since when? Last few log lines. |
| sudo systemctl restart NAME | Stop and start. What you run after deploying a new binary. |
| sudo systemctl stop NAME | Stop it. It stays stopped until you start it or reboot. |
| sudo systemctl disable NAME | Stop it starting at boot. |
| journalctl -u NAME -f | Follow the live log. This is where your app's output goes. |
| journalctl -u NAME -n 100 | The 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
| Engine | Shape | RAM here | Reach 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. |
| MariaDB | Relational | ~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. |
| SQLite | Relational, 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 / Redis | Key-value, in memory | 50–200 MiB | Sessions, caching, rate limits, job queues. A companion to a real database, not a replacement. |
| MongoDB | Document | ~1 GiB+ | Rarely worth it here. Postgres's jsonb does documents well and costs a fraction of the memory. |
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)
$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.
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
Your database holds public keys. Dumping it gives an attacker nothing usable.
Phishing
A passkey is cryptographically bound to your domain. A lookalike site cannot trigger it.
Reused passwords
There is no password to reuse, forget, leak or reset.
You maintain
No reset emails, no strength rules, no hashing decisions, no breach notifications.
How the flow works
- 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.
- 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.
- 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:
| Endpoint | Does |
|---|---|
| POST /register/begin | Creates a challenge, stores it in the session, returns options to the browser. |
| POST /register/finish | Verifies the response, saves the credential (public key + ID + sign counter). |
| POST /login/begin | Creates a fresh challenge for an existing user. |
| POST /login/finish | Verifies 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.
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
- 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.
-
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. - 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.
- Make a service account IAM → Service Accounts → Create. This is the identity your app logs in as. Download its JSON key.
- 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"
| Role | Can | Give it to |
|---|---|---|
| storage.objectViewer | Read objects | Anything that only serves files. |
| storage.objectCreator | Write, but not overwrite or delete | Upload-only paths. Very safe. |
| storage.objectAdmin | Read, write, delete objects | Your main app. The usual choice. |
| storage.admin | All of the above, plus delete the bucket | Nothing. Never grant this to an app. |
/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.
| What | Roughly | Notes |
|---|---|---|
| Standard storage | $0.020 / GB / month | The default. Instant access, no retrieval fee. |
| Nearline | $0.010 / GB / month | 30-day minimum, plus ~$0.01/GB to read. For backups. |
| Coldline | $0.004 / GB / month | 90-day minimum. Archives you hope never to need. |
| Archive | $0.0012 / GB / month | 365-day minimum. Cheapest to keep, dearest to touch. |
| Class B ops (reads) | $0.0004 / 1,000 | A GET of one object. Cheap. |
| Class A ops (writes & lists) | $0.005 / 1,000 | 12× a read. Listing a bucket is a Class A op. |
| Egress to the internet | ~$0.12 / GB | This 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
50 GB × $0.02. Trivial.
Read operations
100,000 GETs. Also trivial.
Egress
500 GB of audio out. 98% of the bill.
If you also listed
One bucket list per page load, 1M loads. Pure waste.
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:
gs://bucket/songs/ to find out what songs exist.
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).mp3brings 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, immutableon 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)
Limits of this box
What it comfortably does, and what will make it unhappy.
CPU
Intel(R) Core(TM) i9-14900K. Plenty for serving; a build will use both.
Memory
The real constraint. Plus 2.00 GiB swap, which is a safety net, not extra RAM.
Disk
50.8 GiB free right now. Not expandable.
Public IP
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 buildor annpm installcompetes 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.
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