TPTito Pinardo Gutiérrez
← Back to the rack
Español
1U · LANDINGRust · Axum · PostgreSQL · 12 MB of RAM In production

Slot 5 of 25 in the rack · In production

1U unit · Personal project in production

This site

Public code · CI green

A single Rust binary carrying the templates, styles, texts and even the CV. It renders every page once at start-up and serves them from memory. The only thing anyone can write, the contact form, is stored in PostgreSQL before I'm notified.

Why Rust
A small binary, no runtime to install, about 12 MB of RAM, and checked at compile time: templates, SQL queries and the texts in both languages.
Where it lives
In its own container with no Docker or privileges, the only one in the homelab that drops everything incoming by default. Only Traefik can talk to it.
What's behind it
Terraform creates the container, Ansible configures and deploys it, and the binary refuses to start if the content has a typo.
  • ~12 MBof RAM in use
  • 0dependencies in the browser
  • 1inbound rule: only from Traefik
  • 0 Bof IP stored: only a salted hash

Inside this site

What you're reading right now comes from here. Pick a flow to see what happens with your visit or with a message.

Hermesvisiting the unit

Click a part or pick a flow. If you came from a skill, the parts where I use it are already lit.

      Your visit
      1. You — You open the site.
      2. Cloudflare — Cloudflare gets it first.
      3. Traefik — Traefik adds the certificate and passes it to the site's container.
      4. Axum server — Axum gets the request. Nothing to look up: the page is already made.
      5. Security headers — The security headers are added.
      6. Pre-rendered pages — Out goes the page in your language, rendered at start-up. If you already had it, a 304 and done.
      7. Your browser — Your browser draws it and the JavaScript adds the map, the terminal, the achievements and the sound.
      8. You — And you're reading it.
      You write to me through the form
      1. You — You fill in the form and send it.
      2. Traefik — It comes in through Traefik, like everything.
      3. Contact form — The fields are validated. If the honeypot field is filled in, it was a bot. If it fell for the AI traps, it still arrives, but flagged.
      4. Sending limits — Limits are checked: per IP (stored only as a hash) and global.
      5. PostgreSQL — The message is saved in PostgreSQL. From here on it isn't lost.
      6. Outbox — A pending notice sits in the outbox.
      7. Matrix notice — A job picks up the notice and sends it to Matrix.
      8. My Matrix server — Matrix confirms with an event ID and the notice is marked delivered.
      9. My phone — It reaches my phone.
      Matrix doesn't answer
      1. Outbox — There's a pending notice.
      2. Matrix notice — It tries to send it, but Matrix doesn't reply.
      3. Outbox — The notice stays in the outbox, with its attempt counted. The message is already saved.
      4. Matrix notice — A while later it's retried. With several workers, each locks a different row and nobody sends twice.
      5. My Matrix server — Matrix answers and confirms: delivered.
      I ship a version
      1. Repository on my Forgejo — I push to my Forgejo. First, a local hook looks for secrets and internal names.
      2. Continuous integration and deployment — CI runs formatting, clippy, every test and gitleaks.
      3. Repository on my Forgejo — On green, the deploy runner repeats the leak check and builds the release.
      4. Ansible — Ansible copies the new binary to the container.
      5. Check before restarting — Before restarting, it runs «landing check»: it validates the content in both languages.
      6. Hardened service — It restarts the service and waits for /healthz to say «ok», which also checks the database.
      7. Axum server — The new version is serving. Only then does the commit go out to GitHub.
      I build the container from scratch
      1. Terraform — Terraform creates the container with its firewall: admin SSH and whatever comes from Traefik get in. Nothing else.
      2. Ansible — Ansible installs and configures PostgreSQL.
      3. PostgreSQL — PostgreSQL not listening on the network.
      4. No network — The site connects through the local socket, with OS authentication.
      5. Hardened service — Ansible creates the hardened service and starts it.
      The map's LEDs
      1. Homelab status — Every 5 minutes a homelab status summary arrives. If it's over 15 minutes old it isn't even published.
      2. Axum server — The site reads it and serves it as a small JSON.
      3. Pre-rendered pages — The home page asks for it on load.
      4. Your browser — And your browser lights each part's LED. If the data is over 2 hours old, it's hidden.
      1. You

        What you're reading right now.

      2. Cloudflare

        In front of the domain: hides my home IP and filters.

      3. Traefik

        The public door. It's the only thing allowed to talk to the site's container.

        • Traefik
      4. My phone

        Where the contact form messages reach me.

      5. Repository on my Forgejo

        Where the code starts, at home. The code is public on GitHub, but that copy only gets what's already deployed and has passed the leak check.

        • Git
        • Forgejo · Actions
      6. Continuous integration and deployment

        On every push, on a runner with no secrets: formatting, clippy with no warnings, every test against a real PostgreSQL and gitleaks. On green, another runner deploys it.

        • Forgejo · Actions
        • GitLab CI · GitHub Actions
      7. My Matrix server

        The room where new-message notices arrive, with a bot of its own that can only write there.

      8. Axum server

        Gets the request, finds the already rendered page and answers. If the browser already has it, it answers 304 with no body.

        • Rust
      9. Pre-rendered pages

        Compiled templates filled in once at start-up, in both languages, each with its ETag.

        • Rust
      10. Security headers

        A strict CSP with nothing inline, HSTS and the rest. That's why JavaScript only enhances the page: everything works without it.

        • Rust
      11. Content in TOML

        Texts, projects, lab parts and these units live in TOML files validated at start-up. A key missing in one language stops the deploy.

        • Rust
      12. Contact form

        Validates the fields, has a honeypot field for bots, traps that recognise an AI and an optional captcha.

        • Rust
      13. Sending limits

        Per IP and global, worked out with a single indexed query. The IP is never stored: only a hash with a secret salt.

        • Rust
        • PostgreSQL
      14. Outbox

        The message is saved first and the notice is left pending. A job retries it until Matrix confirms with an event ID.

        • Rust
        • PostgreSQL
      15. Matrix notice

        Sends the notice to the room with the site's own bot. Several workers don't step on each other: each takes a different row.

        • Rust
      16. PostgreSQL

        Stores messages and pending notices. Queries are checked against the schema at compile time.

        • PostgreSQL
      17. No network

        PostgreSQL listens on no interface: only on the local socket with OS authentication, no password to steal.

        • PostgreSQL
      18. Homelab status

        Every 5 minutes the hypervisor drops a summary: services running, availability and the LED for each map part. Aggregates only.

      19. Your browser

        Keeps your achievements, where you were and whether you want sound. None of it leaves your browser.

      20. Terraform

        Creates the container with its resources, network and firewall: the only one with a «drop» inbound policy.

        • Terraform
      21. Ansible

        Installs PostgreSQL, the binary and its service, with secrets encrypted in the vault.

        • Ansible
      22. Check before restarting

        The role runs «landing check» with the new binary: if the content isn't valid, nothing is restarted.

        • Rust
        • Ansible
      23. Hardened service

        Read-only file system, no capabilities, a syscall filter, capped memory. After restarting, it waits for /healthz to say «ok».

        • Ansible

      What I use from my skill tree

      Pick a skill: the parts where I use it light up on the map and I tell you what for.

      Rust
      The whole server: Axum, compiled askama templates, content validated at start-up, security headers and the job that retries notices.
      PostgreSQL
      Messages and pending notices, queries checked at compile time with sqlx, skip-locked row locking for retries and socket-only access.
      Traefik
      The only way in: certificate, headers and a route generated from the service list.
      Terraform
      Creates the site's container, its resources and its firewall.
      Ansible
      The role that installs PostgreSQL, deploys the binary, checks the content and waits for healthz.
      Git
      A public repo with cleaned history, a hook that blocks secrets before every push and the same check before every release.
      Forgejo · Actions
      The repository's origin and its CI/CD: checks on a runner with no secrets, deploys only on green and publishes the GitHub copy afterwards.
      GitLab CI · GitHub Actions
      GitHub Actions repeats formatting, clippy and every test on the public copy.

      Incident log

      Real failures. Read the symptom, pick the cause you think it was and see how it was fixed.

      1. #1

        «Peer authentication failed for user anonymous»

        04-10-2026 · first deploy

        The first start in the container failed: PostgreSQL rejected the user «anonymous». Fixing that brought up «empty host».

        What do you think was going on?

        Show the fix
        Cause
        With OS authentication over a socket there's no password, but the database library needs the user written in the URL and a nominal host: it doesn't work them out.
        Fix
        The connection URL carries the explicit user and «localhost» as a nominal host, with the socket path separate.
        Lesson
        Dropping the password is safer, but it changes what the library needs. Read the error twice: the second one said exactly what was missing.
      2. #2

        «In Firefox the lab is broken»

        04-10-2026 · browser

        In Firefox the lab showed up as a list of drop-downs and cards, with no map. In other browsers, fine.

        What do you think was going on?

        Show the fix
        Cause
        Quick view, which shows the essentials without animations, had been switched on in that browser at some point. It was stored with no expiry and the site looked broken.
        Fix
        Quick view expires after 30 minutes and, while on, the lab says it's in quick view and offers a button to leave.
        Lesson
        Whatever is remembered for the visitor must expire or be visible. An invisible state that lasts forever looks like a bug.
      3. #3

        Deploys that didn't get through

        05-10-2026 · operations

        Deploys reported «unreachable» halfway and the database tests timed out, with nothing changed.

        What do you think was going on?

        Show the fix
        Cause
        On the first Sunday of the month the system RAID is checked end to end for about 5 hours. With the disks maxed out, a PostgreSQL checkpoint took 1,190 seconds.
        Fix
        Deploy with the role's same steps separately (copy, check, restart, healthz), validate the full suite in CI and rerun the whole playbook afterwards: nothing pending.
        Lesson
        Know which scheduled jobs the server runs before blaming the code. And don't let the quality check depend on a single machine.
      4. #4

        A repository that had to go public

        04-10-2026 · security

        The code was going to be public, but its history had been written with a private repository in mind.

        What do you think was going on?

        Show the fix
        Cause
        A Git history keeps everything that was ever there, even if it's gone now: planning notes, internal names or configuration.
        Fix
        History rewritten and cleaned before the first push, a local hook blocking secrets, internal addresses and specific names, and an audit of a clean clone after every push.
        Lesson
        Deleting something in a new commit doesn't delete it from history. What gets published is reviewed beforehand, and reviewed again from outside.

      How it was built

      1. 04-10-2026

        Plan and content

        • Decisions: Rust and Axum, PostgreSQL done properly even if the traffic doesn't need it, contact messages stored and notified to Matrix.
        • Content taken from my documentation and cleaned: no addresses or internal names.
      2. 04-10-2026

        The server and its container

        • A binary with everything inside and pages rendered at start-up.
        • Its own container with the strictest firewall and PostgreSQL over a socket only.
        • Published again, after taking it off the internet on 26-09 for security.
      3. October 2026

        The lab and the game

        • Hermes as a scripted guide, a server map with animated flows, detective mode and a terminal.
        • Live status for each part and a server-rack look with gnomes.
        • Rack units: a page per part to see it from the inside, with server-room sound.

      Decisions (and why)

      • How pages are served

        Rendered once at start-up

        The content doesn't change while the binary runs: rendering it on every visit would be repeated work. From memory and with ETag, almost no visit costs anything.

      • What if the notice fails

        Save first, notify later

        The message is what matters, not the notice. With an outbox, Matrix not answering never loses a message.

      • What it keeps about whoever writes

        A hash of the IP, never the IP

        To limit sending it's enough to know whether two requests come from the same place. A hash with a secret salt can be compared without going back to the IP.

      • Messages written by an AI

        Recognise them, don't block them

        A recruiter asking their assistant to write to me is legitimate, but I want to know. The form carries a note only machines read, a field only they see and some measurements of how it was filled in (without keeping anything that's typed). The message always arrives, with a line saying what was seen. Tested with two local models: one fell for both text traps and the other didn't; for that one, there are the browser measurements.

      • JavaScript

        Progressive enhancement, no libraries

        Everything reads without JavaScript and the CSP forbids any inline script. The map, terminal, achievements and sound are a layer on top.

      What I learned

      • A binary with everything inside is easy to deploy, roll back and reason about.
      • Validating content at start-up turns a typo into a deploy that doesn't happen, not a broken page.
      • Whatever is public gets audited from outside, after publishing.

      What's next

      • Cache the HTML on Cloudflare with a rule of its own.
      • Turn on the form's captcha if bots ever show up.

      Shall we talk?

      I can walk through any of these decisions in detail in an interview.

      Download CV Get in touch ← Back to the rack

      The code is public on GitHub. No addresses, internal ports or secrets appear here.