A two-part project:
| Part | Where it runs | What's in it |
|---|---|---|
| Frontend | Lovable (or any static host / Node host) | TanStack Start app — public site |
| Backend | Your VPS (Docker Compose) | Express + Prisma + PostgreSQL + JWT admin |
The frontend can run in two modes (toggle live with the on-screen "Data Source" switch):
- Mock mode — uses bundled seed data in
src/lib/seed-data.ts. No backend needed. - API mode — calls your self-hosted backend at
VITE_API_BASE_URL.
.
├── backend/ # Express + Prisma API (deployed to your VPS)
│ ├── prisma/ # schema.prisma + seed.ts
│ ├── src/
│ │ ├── lib/ # prisma client, validation, JWT auth
│ │ └── routes/ # profile, skills, services, projects,
│ │ # testimonials, plugins, contact, auth, admin
│ ├── Dockerfile
│ └── .env.example
├── docker-compose.yml # Postgres + backend stack (run from repo root)
├── src/ # TanStack Start frontend (Lovable)
│ ├── routes/ # /, /projects, /plugins, /services, /about, /contact
│ ├── components/site/ # Header, Footer, DataSourceSwitch, motion helpers
│ ├── lib/ # api-client, data-source toggle, seed-data
│ └── types/portfolio.ts
└── README.md # ← you are here
The frontend builds automatically on every Lovable preview. Locally:
bun install
bun run devTo point it at your live backend, set VITE_API_BASE_URL (build-time) or
use the on-screen Data Source switch (runtime, stored in localStorage):
echo "VITE_API_BASE_URL=https://api.yourdomain.com" > .env.local
bun run dev- Docker 24+ and Docker Compose v2
- A domain pointing at the server (e.g.
api.yourdomain.com) - Caddy or Nginx in front for HTTPS (example Caddy snippet below)
# On your VPS
git clone <this-repo> portfolio && cd portfolio
# 1. Configure environment
cp backend/.env.example backend/.env
nano backend/.env
# - Set strong DB_PASSWORD
# - Set FRONTEND_ORIGIN to your real frontend URL
# - Set ADMIN_EMAIL + ADMIN_PASSWORD (admin login)
# - Set JWT_SECRET to a 64-byte random hex:
# openssl rand -hex 64
# Make sure DATABASE_URL matches the DB_USER/DB_PASSWORD/DB_NAME above.
# 2. Start the stack (Postgres + API)
docker compose up -d --build
# 3. Apply schema and seed initial content
docker compose exec backend npx prisma migrate deploy
docker compose exec backend npx prisma db seed
# 4. Verify
curl http://localhost:4000/healthz # → {"ok":true}
curl http://localhost:4000/api/plugins# View logs
docker compose logs -f backend
# Restart after editing .env
docker compose restart backend
# Pull new code & rebuild
git pull
docker compose up -d --build
docker compose exec backend npx prisma migrate deploy
# Backup the database
docker compose exec db pg_dump -U portfolio portfolio > backup-$(date +%F).sql
# Restore
cat backup-2026-01-01.sql | docker compose exec -T db psql -U portfolio portfolio/etc/caddy/Caddyfile:
api.yourdomain.com {
reverse_proxy localhost:4000
encode gzip
}
Then close port 4000 to the world (ufw deny 4000) and only expose 80/443.
-
FRONTEND_ORIGINis set to your real frontend domain (not*) -
JWT_SECRETis 64+ random hex characters -
ADMIN_PASSWORDis long (20+ chars) and unique - Postgres port 5432 is not exposed publicly (remove the
ports:mapping indocker-compose.ymlfordbonce everything works) - Daily
pg_dumpcron job - Caddy/Nginx HTTPS in front, port 4000 firewalled
-
fail2banwatching SSH
Once the backend is live at https://api.yourdomain.com:
- Open the deployed site.
- Click the Data Source switch (bottom-right floating widget).
- Switch from "Mock" to "API", paste your URL, click "Test connection".
- The whole site now renders live data.
The choice is stored in localStorage per visitor. To bake it in for everyone,
set VITE_API_BASE_URL=https://api.yourdomain.com and rebuild the frontend.
We use JWT bearer tokens with a single admin account whose credentials
live in backend/.env (never in the database, never in the frontend bundle).
| Threat | Mitigation |
|---|---|
Brute force on /api/auth/login |
express-rate-limit: 10 attempts / 15 min / IP |
| Stolen JWT replay | Short expiry (JWT_EXPIRES_IN=12h), HTTPS-only via Caddy |
| Password leak from DB dump | Password is not in the DB — only in .env |
.env exfiltration |
File is in .gitignore, owned by root, mode 600 recommended |
| Username enumeration | Login returns the same 401 Invalid credentials whether email or password is wrong; bcrypt compare runs even on unknown email (constant-ish timing) |
| CSRF on admin endpoints | Bearer-token auth (not cookies) — not vulnerable to CSRF |
| XSS stealing token | Token lives in localStorage of admin browser only; admin UI is served over HTTPS; no untrusted user content is rendered as HTML on the admin pages |
| Server-side injection | All inputs validated with Zod; Prisma uses parameterized queries; helmet sets safe headers |
| Privilege escalation | Single role (admin); no user-supplied role claims; JWT signed with server-only JWT_SECRET |
POST /api/auth/login body: { email, password }
→ 200 { token, expiresIn } on success
→ 401 { error } on bad credentials
→ 429 after 10 failed tries / 15 min
GET /api/auth/me Authorization: Bearer <token>
→ 200 { email, role: "admin" }
# Admin CRUD (all require Authorization: Bearer <token>):
GET /api/admin/:slug
GET /api/admin/:slug/:id
POST /api/admin/:slug
PATCH /api/admin/:slug/:id
DELETE /api/admin/:slug/:id
# Where :slug ∈ plugins | projects | services | skills |
# testimonials | profile | messages
# 1. Get a token (12h validity)
TOKEN=$(curl -s -X POST https://api.yourdomain.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@samehnaim.dev","password":"YOUR_ADMIN_PASSWORD"}' \
| jq -r .token)
# 2. List plugins
curl https://api.yourdomain.com/api/admin/plugins \
-H "Authorization: Bearer $TOKEN"
# 3. Add a new plugin
curl -X POST https://api.yourdomain.com/api/admin/plugins \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "My New Plugin",
"slug": "my-new-plugin",
"description": "Does cool things in Moodle.",
"category": "Moodle plugin",
"url": "https://moodle.org/plugins/local_mynewplugin",
"featured": true,
"order": 10
}'
# 4. Update a plugin
curl -X PATCH https://api.yourdomain.com/api/admin/plugins/<id> \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "featured": false }'
# 5. Delete
curl -X DELETE https://api.yourdomain.com/api/admin/plugins/<id> \
-H "Authorization: Bearer $TOKEN"A small admin web UI is not included in this MVP. You can either:
- Use the curl examples above
- Use Prisma Studio over an SSH tunnel:
ssh -L 5555:localhost:5555 vps "cd portfolio && docker compose exec backend npx prisma studio"- Build a small
/adminReact page in a future iteration that calls these endpoints
# Change the admin password
nano backend/.env # update ADMIN_PASSWORD
docker compose restart backend
# Rotate JWT secret (invalidates ALL existing tokens — you'll have to log in again)
JWT_SECRET=$(openssl rand -hex 64) && nano backend/.env # paste it in
docker compose restart backendYou have three editing paths, in order of convenience:
- Admin API (production) — see "Admin login from the command line" above.
- Re-seed (dev / fresh deploys) — edit
backend/prisma/seed.ts, then:docker compose exec backend npx prisma db seed⚠️ This wipes projects/services/skills/testimonials/plugins/profile and re-creates them. Contact messages are preserved. - Mock mode in the frontend (no backend) — edit
src/lib/seed-data.tsand the changes ship with the next frontend build.
When the frontend is in API mode, mock edits are not visible to visitors — only the database is.
Say you want to add Article:
- Backend schema — add a
model Article {}tobackend/prisma/schema.prisma. - Migration —
docker compose exec backend npx prisma migrate dev --name add_articles - Public route — create
backend/src/routes/articles.ts(copyplugins.ts). - Register it in
backend/src/index.tsasapp.use("/api/articles", articlesRouter). - Admin slug — add
articles: "article"toslugMapinbackend/src/routes/admin.ts. CRUD is automatic. - Frontend type — add
Articleinterface tosrc/types/portfolio.ts. - Frontend client — add
getArticles()tosrc/lib/api-client.ts. - Frontend page — create
src/routes/articles.tsx(copyplugins.tsx). - Nav link — add to
navItemsinsrc/components/site/Header.tsxand the sitemap insrc/components/site/Footer.tsx.
That's it.