Part 1 of the series “My own website (rukado.com)”
Inside This Site
A Laravel + Next.js monorepo, and the decisions behind every layer
Every developer portfolio has an "about" page. Very few explain how they were built. This post opens the hood.
It isn't a feature tour — it's a tour of decisions. For each layer, the interesting question isn't "what did I use", it's "what did I choose not to do, and why". If you're building something similar, that's the part that saves you time.
The map
| Layer | Choice | Why, in one line |
|---|---|---|
| Repository | Monorepo, apps/api + apps/web |
Two real applications, one history |
| Backend | Laravel 13 + PHP 8.5 | Read-only v1 API plus the admin panel |
| Admin | Filament 5, 26 resources | The CMS is the panel — no external service |
| Database | PostgreSQL 18 | tsvector, jsonb, partial indexes — the database works |
| Frontend | Next.js 16 (App Router) + React 19 | Server Components consuming the API |
| UI | Mantine 9 + PostCSS | Accessible components, no runtime CSS-in-JS |
| i18n | next-intl, native routes per language |
/artigos/… and /articles/… are both canonical |
| Tests | Pest 5 (API) + Jest/RTL (web) | 30 test files on the front end, feature tests on the API |
| Deploy | Docker + Coolify, 2 vCPU / 4 GB VPS | Builds on the server itself, with guardrails |
Now the parts that deserve an explanation.
1. Translation is schema, not if
The classic bilingual-site mistake is storing title_pt and title_en on the same row. It works with two languages and breaks on the third.
Here every piece of content has two tables:
articles— what doesn't change with language: author, category, series, cover,published_at, counters.article_translations— what does:title,slug,subtitle,excerpt,body_markdown,reading_minutes,word_count, SEO fields.
What that buys:
- One slug per language.
unique(['locale', 'slug'])— this article can be/articles/inside-this-siteand/artigos/por-dentro-deste-site, no collision. - Status per translation. The English text can sit in draft while the Portuguese one is already live.
- A new language is a new row. No migration, no column.
- A machine-translation trail.
is_machine_translatedandtranslated_atexist so "this was machine translated" is a fact in the database, not a guess.
The locales table is the source of truth — article_translations.locale is a foreign key into it. An invalid language code simply cannot enter the database.
2. Let the database do the heavy lifting
There's a strong pull toward solving search with LIKE %term% and moving on. Three things here run in Postgres on purpose:
Full-text search that's correct by construction. The search_vector column is GENERATED ALWAYS AS ... STORED. Nobody has to remember to refresh an index after saving — it cannot drift out of sync with its row. And it's language-aware:
CASE WHEN locale LIKE 'pt%' THEN 'portuguese'::regconfig
ELSE 'english'::regconfig END
Portuguese text is indexed with the Portuguese stemmer. Searching "configurações" finds "configuração".
The weights matter too: title is A, subtitle and excerpt are B, body is C. A term in the title outranks the same term buried in the body.
A partial index for the query that actually runs. The article listing always asks the same question: published, public, newest first.
CREATE INDEX articles_feed_idx ON articles (published_at DESC)
WHERE status = 'published' AND visibility = 'public' AND deleted_at IS NULL
Smaller index, warmer in cache, and it only covers rows anyone actually asks for.
Business rules as constraints. An article inside a series occupies a position, and two articles shouldn't share a position in the same series. That's not form validation — it's a partial unique index. PHP-side validation gets bypassed by a careless seeder; a database constraint doesn't.
The rule: if integrity matters, it lives in the database. Form validation is a convenience for the user, not a guarantee.
3. A URL that once existed still exists
You publish an article, someone shares it, and three days later you spot a typo in the title. You fix the slug — and break every link already in circulation.
Here, whenever a translation's slug changes, the old value is written to article_slug_history. A write-once table with no updated_at, because the row is created and never touched again.
The recovery path is neat because it solves two problems with one endpoint. When a URL misses, the front end calls /articles/{slug}/alternates — the one read in the API that is deliberately not scoped to the requested locale. It answers for:
- a visitor switching language mid-article;
- a link shared with the other language's audience;
- and a retired slug, which is exactly the same kind of URL pointing at exactly the same article.
Live slugs are searched first. If an old address has since been taken up by another article, it resolves to whichever article holds it now, not the one that used to.
There's also a Redirect model for manual redirects. They all solve the same problem from different directions: a link that worked once should keep working.
4. The front end degrades — it doesn't fall over
API-backed front ends have an ugly failure mode: the API goes down and the whole page becomes a 500 — including the header, the footer and the nav, none of which needed the API at all.
The API client here has three levels:
| Helper | Behavior | Use it for |
|---|---|---|
apiResource / apiPaginated |
Throws ApiError |
The page's essential data |
apiOptional |
404 becomes null, everything else throws |
A detail that may not exist → notFound() |
tolerate |
Any failure becomes null, and logs |
Peripheral layout data |
tolerate is the detail that matters. apiOptional covers 404s — but an API that is genuinely down returns no status at all: fetch rejects with a TypeError long before there's a status to inspect. Different cases, and the test suite is explicit about it.
And it always logs. A silent fallback is how a production bug becomes permanent: the page looks fine, the data is gone, and nobody knows for how long.
5. CI that tests the real thing
Two small decisions that eliminate an entire class of "green in CI, broken in production".
Real Postgres, not SQLite. The schema uses tsvector, jsonb and partial indexes. Testing on SQLite would be testing a different database. The workflow spins up postgres:18 as a service — the same major that runs in production. A migration that only works on one of them fails in CI, not at deploy time.
The front-end build never talks to the API. No page is prerendered, and the build container can't even see the app's Docker network. That's an invariant, and CI protects it: if someone adds a build-time fetch, the build fails on the PR instead of failing on the server, where the internal hostname doesn't resolve.
The front-end gate runs cheapest-first: typegen → oxfmt → oxlint → stylelint → tsc → jest. A formatting error doesn't cost you a full test suite.
Both workflows are path-filtered — an API change doesn't rebuild Next, and vice versa. And each workflow file includes itself in its filter: changing how the check runs also has to be checked by running it.
6. Infrastructure: the incident that became three guardrails
This site runs on a 2 vCPU / 4 GB VPS. On 2026-08-07, the whole server was down for seven hours.
What happened: two Docker builds ran at the same time, RAM ran out, and with no swap the kernel entered page-reclaim thrashing — ~1 GB/s of disk reads, CPU pinned at 180%, and no OOM kill to end it, because without swap there is always reclaimable page cache. Coolify's own dashboard went down with it.
The fix wasn't "buy more RAM". It was three independent guardrails:
| Guardrail | What it does |
|---|---|
Concurrent Builds = 1 |
Never two builds at once again (the default was 2) |
| Per-app watch paths | A push touching only the API doesn't build Next |
| swap + earlyoom | A build that outgrows RAM dies and fails the deploy, instead of stalling the host |
Plus mem_limit on every container, which is the other half: the limit lives in the container's cgroup, so an overrun is killed inside that container and never becomes a host-wide stall. The numbers add up to ~2.2 GB across API, worker, scheduler, Postgres, Redis, SeaweedFS and Next — leaving ~750 MB free for a build to run in.
One detail you only learn the painful way: PHP_FPM_PM_MAX_CHILDREN: 6. The serversideup/php image sizes the FPM pool from the host's RAM and can't see the cgroup limit. On defaults, the pool grows straight through the 448 MB ceiling and OOMs the container under load.
7. Content is data, not files
Nothing here is markdown committed to the repo. Articles, projects, experience, certifications, achievements, support methods — it all lives in the database and is edited in Filament.
Seeders carry reference data, and they run on every deploy. That's only safe because idempotency is a contract: the seeders use updateOrCreate/firstOrCreate, and a SeederIdempotencyTest fails CI if any seeder ever stops being idempotent. The rule doesn't depend on anyone remembering it.
A checklist to take with you
If you're building something similar:
- Translations in a separate table with
unique(locale, slug)— not_pt/_encolumns - Index the query that actually runs, not the whole table
- Generated columns when a value derives from its row — manual sync always gets forgotten
- Integrity rules as constraints; form validation is UX, not a guarantee
- Slug history before you allow slug edits
- An explicit path for "the API is down" that isn't a 500
- A fallback that logs — invisible degradation is a permanent bug
- CI on the same database and the same major as production
- Build invariants enforced by CI, not by memory
-
mem_limiton every container if builds run on the same host - If seeders run in production, an automated test that guarantees idempotency
None of this is sophisticated. It's just the difference between a project you abandon in six months and one you can still work on after a year away.
Upcoming posts go deeper into each of these. If you want a specific one first, the contact page is open.