This Portfolio — The Site You Are Reading
A bilingual portfolio whose entire content is editable from an admin panel without redeploying — with the admin reachable only over Tailscale, nightly encrypted backups, and a restore procedure actually tested against the live server.
- Docker
- Next.js
- TypeScript
- React
- SQLite
- Drizzle ORM
- Tailwind CSS
- Caddy
- Tailscale
The problem
Most portfolios require a redeploy for every content change. I wanted one where everything is editable from the browser — profile, projects, skills, experience, images, CV file, in both Thai and English.
But an admin panel means a public login page, a file upload endpoint, and a database whose loss takes the entire site's content with it. The real problem was never "make a good-looking portfolio" — it was "build a small system that looks after itself on one machine with no ops team."
What I built
I wrote the full specification before the first line of code and froze it, so scope could not creep mid-build.
For security I chose not to protect the admin panel with an obscure URL or a password alone, but to cut it off at the network layer: the admin panel is not bound to the machine's public interface at all — it listens only on the Tailscale IP — and the public domain returns 404 for every path under `/admin`. Even with a leaked password, someone outside the tailnet never reaches the login page.
Locale lives in an allowlisted cookie so the server can read it and render correct SEO metadata; theme lives in localStorage and switches through pure CSS, so it never waits on JavaScript and never flashes on load.
Architecture
Next.js App Router (React Server Components + Server Actions) → SQLite through Drizzle ORM in WAL mode → a standalone build running in Docker.
Caddy sits in front doing two jobs at once: the public domain gets a Let's Encrypt certificate, while the admin port uses Caddy's Tailscale certificate manager, which issues publicly-trusted certificates for `.ts.net` names — so the admin never has to click through a browser warning.
Requests arriving with an unexpected Host hit a catch-all that returns a bare 404 and advertises nothing. Sessions are `__Host-`-prefixed cookies sealed with iron-session, with a `session_version` column that revokes every outstanding session at once.
My contribution
Built entirely by me, from specification to deployment: schema, admin panel, auth, uploads, the Caddy configuration, a non-root Docker image, the backup and restore scripts, and the VPS installation itself.
Challenges and trade-offs
The admin panel was unreachable, and I misdiagnosed it twice. TLS returned internal error. First I blamed Docker NAT. Then I tested against 127.0.0.1 — a different code path from the real one — so my fix appeared to work while changing nothing. Only reading the actual Caddy log revealed the single line that explained everything: Caddy's Tailscale certificate manager activates automatically for .ts.net names and overrides the issuer I had configured, and it was failing because the tailscaled socket was not mounted into the container. The lesson that genuinely changed how I work: read the logs before building an experiment, because an experiment on a different code path answers confidently and wrongly.
The admin port leaked through a redirect. Visiting http://<ip> produced a 308 to https://<ip>:8443 — announcing exactly where the admin lived. Caddy generates an HTTP-to-HTTPS redirect per site and had taken the port from the site addressed without a hostname. Fixed by disabling automatic redirects and writing an explicit one for the public domain only.
A backup script that existed but could not actually run. Running it on the real VPS was what revealed it needed root, and that the host had no sqlite3 installed. I added explicit privilege checks with a clear message, and made verification run through the container rather than depending on a host binary.
Result
Live at jakkapat.dev with a Let's Encrypt certificate, verified by probes from outside the network: the public page returns 200, `/admin` returns 404, an unexpected Host on port 80 gets a bare 404 with no port disclosure, an unknown SNI on 443 is refused at the TLS layer, and both the admin port and port 3000 are unreachable from the public IP.
Encrypted backups run nightly at 04:00, verified to contain a real database rather than an empty file — and I have performed one real restore against the production machine, because a backup that has never been restored is not yet a backup.