Back to Experiments

Experiment 011

Stable Full backend · Frontend in EXP 010 →

One Auth Backend for Many Frontends — PHP, SQLite & Docker Entry Point in a Day.

Can one small auth backend — PHP + SQLite + Docker, no framework, no Composer — serve many frontends with real OTP, opaque tokens, rate limits and per-user Job Tracker sync, built with only a 35B local coder?

Same Tiel-Coder-35B via llama.cpp-server · 1 image · 1 file · 0 deps · Live daily 08.00–21.00 WIB (UTC+7)

System Requirements

CPU

Intel Core i5-11400F

RAM

16GB DDR4 3200MT/s

GPU

RX 6700 XT 12GB

OS

Ubuntu Desktop 26.04 LTS

Model

Tiel-Coder-35B-A3B-MTP-UD-Q4_K_S.gguf

Engine

llama.cpp-server

Stack

PHP 8.3 + PDO SQLite + Docker

Time / Status

Same 35B local coder / STABLE

Same local coder as EXP 010 via llama.cpp-server. Inference flags in EXP 001. Backend runs via 1 Docker image (php:8.3-cli + pdo_sqlite), 1 SQLite file, 0 Composer packages.

Screenshots

Terminal proof — no UI to hide behind. If the API is offline when you try curl, these still show exactly how it answers.

Docker backend running on port 7002
Docker — 1 command up
Health check returning ok without login
Health — ok without login
Rate limit returning 429 after 5 tries
Rate — 429 after 5 tries
Register verify login me flow with redacted secrets
Auth — register to me
CORS preflight allowing frontend origin
CORS — preflight 204
SQLite schema showing 6 tables structure only
DB — structure only, no rows

Live API

Talk to it with curl — no UI needed.

Backend live daily 08.00–21.00 WIB (UTC+7) when my PC is on. Outside those hours curl fails — expected (see Live Status).

Health — no login needed

curl -s localhost:7002/api/health
# {"ok":true,"time":"2026-09-09T00:00:00Z"}

Auth — register to me (redacted)

curl -X POST localhost:7002/api/register -H 'Content-Type: application/json' -d '{"email":"you@mail.com","nama":"You","telepon":"08123456789","password":"Rahasia123","password_konfirmasi":"Rahasia123"}'
curl -X POST localhost:7002/api/verify -H 'Content-Type: application/json' -d '{"email":"you@mail.com","kode":"******"}'
curl -X POST localhost:7002/api/login -H 'Content-Type: application/json' -d '{"email":"you@mail.com","password":"Rahasia123"}'
curl localhost:7002/api/me -H 'Authorization: Bearer ******'

Job Tracker as client — token required

curl localhost:7002/api/lamaran -H 'Authorization: Bearer ******'
# 200 own rows only. Full UI in EXP 010.

Local base: http://localhost:7002 • Tunnel when PC is on: https://aispec.tail06293c.ts.net • .env never shown

For those of you unable to read this data from technical standpoint, here is the conclusion:

Every new app needs login. Copying login 5 times gets messy fast. I asked myself if 1 small service could serve them all. ✨

I built one small login service on my own machine with local AI — no extra packages. My first try with scattered auth failed and drifted, so I switched to one place that decides everything. 🤝

The Job Tracker is the first customer. Next apps just register their address, no re-code. It sends 6-digit codes via email, gives 1-hour passes, and blocks spam after 5 tries. 💰

It already runs on modest hardware with 1 Docker image and 1 database file. For now it lives on my home PC (08.00–21.00 WIB) — stable for this experiment, not yet 24/7. Reliability matters more than specs. 🛡️

What that means in practice: I only maintain auth once. Every new frontend just does fetch. That is the whole trick. 🎯

Experiment Details

Problem

Am I capable of running login for many apps without rebuilding it every time? I ask because every new webapp needs register + OTP + login + forgot, and copying that 5 times drifts fast.

I need 1 local auth service many frontends can share. Frontend only does fetch. All decisions — hash, OTP, expiry, token — live in 1 backend.

Why This Stack

I locked PHP because it is the same family as my invoice save.php. I need password_hash, random_bytes, PDO — all built-in, 0 Composer packages. I chose Docker because my laptop has no native PHP and I cannot apt install. I chose raw SMTP because every line is traceable: EHLO → STARTTLS → AUTH → MAIL → RCPT → DATA → QUIT. Mailer libs and JWT are production upgrades, not v1.

The Build

I built it with the same Tiel-Coder-35B-A3B-MTP-UD-Q4_K_S.gguf via llama.cpp-server. AI handled syntax and boilerplate, decisions stayed with me. Flags are in EXP 001. Honest scope: local + auth flow working. Full hardening and deploy is a separate project, not here.

Router

public/index.php is the front door. I load .env with no library, check CORS (explicit list + same-host pass), answer OPTIONS 204, then route health / register / verify / login / me / forgot / reset / lamaran. Unknown paths get generic 404/405/500. I keep messages generic on purpose — no internal details leak to the browser.

Auth Flow
  1. 1.

    POST /api/register — I create a pending user and send a 6-digit OTP via Gmail. Password must be ≥8 + upper + digit, checked on the server, not just the browser.

  2. 2.

    POST /api/verify — I flip pending → active and issue a 64-hex opaque token (1 hour). Wrong 5 times locks for 10 minutes. Errors stay generic.

  3. 3.

    POST /api/login + GET /api/me — I check hash, issue token, then guard every open with /api/me. Dead token kicks back to login. I never tell whether the email or the password was wrong.

  4. 4.

    POST /api/forgot + /api/reset — I always answer generic 200 so emails cannot be harvested. Reset re-hashes and wipes all sessions — forced relogin everywhere. OTP 5 min, reset 10 min, by my own setting for local use.

Data Model

I keep 1 file data/auth.db: users (email UNIQUE, hash, nama, telepon, pending→active), otps, reset_tokens, sessions (token + 1h), rate_limits, lamaran (owner email + idx, company, position, tanggal, status, portal, link). I migrate with PRAGMA table_info + ALTER — no data loss. Honest tradeoff: I store OTP plaintext with short expiry locally so I can trace it. I will hash it in production later.

Docker Run

I run php:8.3-cli + pdo_sqlite in 1 image. Command is php -S 0.0.0.0:7002 -t public public/index.php. I start all with docker compose up --build, then serve the webapp with python3 -m http.server 7001. Data persists in ./data/ (gitignored). I need no native PHP at all.

CORS + Limits

Different port means different origin — browsers block fetch until I allow it. I keep an explicit CORS_ORIGINS list (:7001, add :7003 for the next app, no re-code) plus a same-host pass so Tailscale IP changes need no config. I cap sensitive endpoints at 5/min/IP via SQLite. Job Tracker as client: every /api/lamaran needs a token, I check email from token == row owner. UI detail stays in EXP 010.

What Failed
  1. 1.

    CORS blocked everything first. My reasoning was flawed — I forgot different ports are different origins. I fixed it with an allowlist, not a wildcard.

  2. 2.

    Mailpit had to go. I removed it 2026-09-09. OTP must go via real Gmail App Password (16 chars, not login password). No shortcut.

  3. 3.

    1-hour kick looked like a bug. I got kicked to login and thought I broke sessions. It turned out to be expected expiry doing its job.

Evidence

Small numbers that prove it runs — screenshots above, no cold-start tricks.

Docker images

1 image

php 8.3 + sqlite

SQLite files

1 file

6 tables

Auth endpoints

6 endpoints

+ health + tracker

Rate

5 / min

then 429

OTP windows

5 → 10 min

max 5 tries

Dependencies

0 Composer

vanilla SMTP

Backend Code

Router CORS — public/index.php (essence, no secrets)

$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$boleh = array_map('trim', explode(',', $cfg['CORS_ORIGINS']));
$izinkan = in_array($origin, $boleh, true);
// + same-host Tailscale pass, then:
header("Access-Control-Allow-Origin: $origin"); // only if allowed
// OPTIONS → 204, then route health/register/verify/login/me/forgot/reset/lamaran

DB — src/db.php (schema only)

users(email UNIQUE, password_hash, nama, telepon, pending→active)
otps(email, kode 6-digit, +5min, max 5 tries)
sessions(token 64hex, +1h) -- opaque, SELECT to check, DELETE to revoke
lamaran(email owner + idx, company, position, tanggal, status, portal, link)

Guard — token check (essence)

$email = email_dari_token($pdo, $_SERVER['HTTP_AUTHORIZATION'] ?? '');
if ($email === null) json(401, ['error' => 'Token tidak sah atau kedaluwarsa']);
No .env content, no SMTP secrets, no real OTP or tokens here. I keep those off the page on purpose.

FAQ

Frequently Asked Questions

Why PHP + SQLite + Docker instead of Node + Postgres?

Locked for this scale: PHP built-ins already cover hash + random + PDO, SQLite is 1 file with zero server, Docker gives PHP without native install. Postgres + JWT + mailer libs are noted as production upgrades, not v1.

How can :7001 talk to :7002 without being blocked?

Explicit CORS allowlist plus same-host pass for Tailscale. Preflight OPTIONS returns 204 with Allow-Origin only for listed hosts. Next app (:7003) just gets added to the list.

Why does the API sometimes not answer?

Not on a 24/7 VPS yet — home PC, online daily 08.00–21.00 WIB (UTC+7). Outside those hours fetch fails and frontend shows a toast. Screenshots + curl logs remain as proof.

Live Status

Local PC, not yet 24/7 VPS.

✅ What works now

  • Docker 1-command up
  • Health ok without login
  • 6 auth endpoints
  • Gmail OTP delivery
  • 1-hour opaque tokens
  • Rate 5/min anti-spam
  • Job Tracker per-user sync
  • Reusable origins
  • Online daily 08.00–21.00 WIB (UTC+7)

🚧 What isn't production yet

  • Not yet 24/7
  • Still local (personal PC)
  • Early-stage features
  • Collecting feedback
  • Hardening + deploy = separate project
Runs locally on my personal PC, live daily 08.00–21.00 WIB (UTC+7). Off-hours → unreachable, expected. Screenshots + curl logs above still show exactly how it answers.

Disclaimer

Local stable, not production-hardened

Validated with the same Tiel-Coder-35B via llama.cpp-server (flags in EXP 001). OTP plaintext short-expiry locally for learning — I will hash it in production later, by my own note, not a guarantee for every setup. Mailpit removed 2026-09-09, Gmail only.

Frontend: EXP 010 + /jobtracker/ · This: EXP 011 backend · Machine: EXP 001