Farasu on TelegramJoin
Back to portfolio
Next.jsTypeScriptPostgreSQLDockerDesign System

parvanehborzouei.com — a practice platform, and a fourth tenant

A bilingual publishing platform for a psychology practice, live on a two-core Frankfurt VPS that already ran three production services. Built so that no fact about the practitioner can be invented, and so a mistake here cannot reach the neighbours.

Client
Parvaneh Borzouei — psychology practice
Scope
Bilingual publishing platform, infrastructure and deploy pipeline
Role
Solo — architecture, design system, infrastructure, CI
Year
2026

A psychology practice's website, live at parvanehborzouei.com in Persian and English. The interesting engineering was not building it. It was adding a fourth production tenant to a two-core Frankfurt VPS that already ran three others, one of which holds real people's financial records — and doing it while almost none of the client's own content existed yet.

The Problem

Two constraints shaped every decision, and neither of them was a feature request.

Nothing could be invented. At the start of the project the practitioner's biography, credentials, specialties and areas of practice did not exist as copy. For most sites that is a scheduling annoyance, filled with placeholder text until the real words arrive. For a healthcare practitioner it is not: a plausible-sounding credential on a psychology site is a claim about somebody's professional qualifications, published under their name. The site had to be able to go live, publicly, with most of its content still absent — and it had to be structurally incapable of substituting a guess.

The infrastructure budget was one more tenant on a box that was already full. The target server runs Maaleto, which has real user accounts and financial records in its database; an n8n automation stack; a VPN; and this portfolio. Roughly 2.49 GB available, two cores, swap already in use. The failure mode that mattered was not "the new site is slow". It was that a resource mistake or a configuration mistake in the fourth tenant reaches the other three.

The Solution

The deployment shape was ported from a migration completed on the same box weeks earlier, comments included — nearly every one of them records a bug already paid for once. What changed was everything that assumed the box had one tenant.

  • Images build in CI and are pulled from a registry, never built on the server. A Next.js build peaks well above what is free here, and would pin both cores away from the neighbours for the length of every deploy.
  • Port 3002, because 3000 is Maaleto, 3001 is this site, 5678 is n8n and 2019 is Caddy's admin endpoint. Four tenants means the port map is a shared resource, not a default.
  • Migrations run as a one-off container before up -d --wait, so a failed migration is a red deploy rather than a crash loop behind 502s.
  • Caddy terminates TLS with a Let's Encrypt certificate issued over tls-alpn-01 in about ten seconds, and www redirects to the apex with a 308.
  • The nightly encrypted off-site backup covers this database too, patched into the box's existing root-owned backup script rather than bolted alongside it.

Design Decisions

An explicit Compose project name, because the default is a directory name. Docker Compose derives the project name — and therefore the volume names — from the containing directory. Maaleto lives in a directory called repo, so its project is literally repo and it owns the volume repo_pgdata. Any sibling stack that ends up in a directory with that name collides with it, and a docker compose down -v in the wrong place destroys a database holding real user accounts. This project sets its name explicitly and lives in a directory that could not collide anyway. The explicit name is the actual defence; the directory is the backstop.

The PostgreSQL budget was recomputed, not copied. The sibling stack's settings assume it is the only extra database on the host. This is the second, on a machine already in swap, so the numbers were re-derived rather than inherited: 96 MB of shared buffers against the sibling's 192, and fifteen maximum connections against twenty. Both services also carry a hard memory limit, which does nothing on a good day and is the entire point on a bad one — an out-of-memory event in this stack cannot reach Maaleto.

Bilingual copy lives in typed TypeScript, not in JSON message catalogues. This is a deliberate deviation from the sibling site, which uses the conventional messages/en.json and messages/fa.json pair. That pattern has a specific failure: a key present in one catalogue and missing from the other makes the framework render the key path itself as visible page copy, with nothing thrown, nothing logged, and the type checker unable to see it, because message files are data. The sibling needs a runtime parity test to catch it. Here both languages live in one typed structure, so a missing English string is a compile error and the test is unnecessary. It also gives the per-language client-approval flag somewhere to live, which JSON has nowhere to put.

Client facts default to absent, and the code enforces it. Every field of the practitioner's profile is either a marked placeholder or genuinely undefined, and rendering treats undefined as "omit this section" rather than as a cue to substitute something plausible. The About page renders an explicit empty state rather than a shell; the hero drops its subtitle when there is no confirmed title, and drops the portrait column entirely when there is no photograph. Separately, every string is tagged as either approved by the client or still pending, and pending strings carry a visible marker in staging so the client can see exactly what they have not yet signed off. A build argument controls that marker and defaults to off, so the shipped artifact can never render it.

The palette is ink, paper and one lapis. The obvious move for a therapy website is soft teal or clinical blue-and-white, which is why almost every one of them looks like the same purchased template. This one takes its world from the Persian written page instead: a warm paper ground, brown-black iron-gall ink rather than pure black, and لاجورد — lapis, the pigment of Persian illumination and the origin of the word "azure" — as the only accent. An earlier version carried a second warm-clay accent that earned its keep in exactly one place and diluted the signal everywhere else; it was removed rather than reduced.

The display typeface was verified letterform by letterform, not chosen from a list. Most faces that ship an Arabic subset are Arabic-first, and this is a Farsi site. The chosen face was checked in the browser before adoption: that the four Persian-only letters گ چ پ ژ are drawn as Persian forms, that ک and ی render as Persian rather than as the Arabic ك and ي, that the zero-width non-joiner in می‌شود and کتاب‌ها reads as a join rather than a word space, and that digits come out as ۰۱۲۳ rather than ٠١٢٣. A Persian reader catches any of those instantly. None of them are visible in a diff.

The Outcome

The platform is live and serving both locales, with the site's public routes available in Persian and English and the admin area deliberately outside the localised URL structure.

The backups are verified by restoring them, not by checking that a file exists. The real encrypted off-site archive was downloaded, decrypted, its dump extracted and restored into a scratch database, and the row counts diffed per table against production. The administrator record round-tripped with its 161-character password hash intact. A backup nobody has restored is a belief, not a backup.

One incident, told straight. Removing this project's temporary staging block from the shared Caddy configuration used a regular expression that matched exactly once — and matched far too much. An identical comment header on an unrelated block higher in the same file meant the non-greedy match began there and ran through three other hostnames on its way to a closing brace. This portfolio was down for roughly three minutes until the pre-edit backup was restored. An "expected exactly one match" assertion does not catch a single match that is too large. The replacement anchors on the unique hostname line, walks braces forward, absorbs the contiguous comment run backward, and then asserts that the removed slice contains no other top-level block — which is the check that would have caught it.

What is not built yet. The content models, the article and book pages, the video courses and the dual-currency payment layer are the next phase; the database currently holds only the two account tables. The public sections say so plainly rather than showing empty shells. That is the same rule as the client copy: the site states what is true today.

Tech Stack

Next.js 16 (App Router) · TypeScript · React 19 · Tailwind CSS v4 · Prisma 7 · PostgreSQL 17 · next-intl · Docker Compose · GitHub Actions → GHCR · Caddy · systemd