Personal portfolio of Luca Tomei, software engineer in Rome. Bilingual (IT/EN), dark/light theme, self-hosted with Docker.
src/app/globals.css)src/
proxy.ts # locale negotiation (/, /x → /it/x or /en/x)
app/
[locale]/ # it | en
layout.tsx # html shell, fonts, metadata, JSON-LD, theme script
page.tsx # single-page portfolio (hero → contact)
projects/electric-scooter-wheelchair/ # thesis case study
opengraph-image.tsx # generated OG image, per locale
not-found.tsx, error.tsx
api/health/ # GET /api/health → {"status":"ok"}
api/cv/[locale]/ # CV download from the MyCV GitHub releases
api/visit/ # Telegram visitor notification endpoint
robots.ts, sitemap.ts, manifest.ts, icon.svg, apple-icon.png
components/ # Navbar, Hero, sections, theme/locale switchers
data/ # ALL content lives here (see “Editing content”)
i18n/ # locale config + typed dictionaries
public/images/ # optimized WebP assets
public/docs/thesis.pdf # master's thesis
Content and presentation are separated: the components render whatever is in
src/data and src/i18n, so day-to-day edits never touch layout code.
npm install
npm run dev # http://localhost:3000
Checks:
npm run lint
npm run typecheck
npm run build # production build
npm run start # serve the production build
Copy .env.example to .env:
| Variable | Required | Purpose |
|---|---|---|
SITE_URL |
no | Public URL for canonical/sitemap/OG — build-time (baked into the static pages) |
TELEGRAM_BOT_TOKEN |
no | Bot token for visitor / CV-download notifications |
TELEGRAM_CHAT_ID |
no | Telegram chat that receives the notifications |
GITHUB_TOKEN |
no | Only raises the GitHub API rate limit for /api/cv lookups |
The PDFs are not stored in this repository. GET /api/cv/it and
GET /api/cv/en resolve the CV in this order (all cached for one hour):
latest release of the public repo LucaTomei/MyCV
(asset name containing _IT_ / _EN_, .pdf).dist/ folder of MyCV (compiled copies committed by its CI).MyCV releases page.MyCV has its own GitHub Action: every push touching the LaTeX sources
compiles both PDFs, refreshes dist/ and updates the latest release — so
updating the CV online only requires editing the .tex and pushing.
/it/... and /en/...; src/proxy.ts redirects bare paths using
the locale cookie, then the Accept-Language header.src/i18n/dictionaries/{it,en}.ts. The Italian file
defines the Dictionary type; every other locale must satisfy it, so a
missing key is a compile error.src/data uses Localized fields ({ it: "...", en: "..." }).Adding a language (e.g. fr):
"fr" to locales in src/i18n/config.ts.src/i18n/dictionaries/fr.ts (export const fr: Dictionary = {...})
and register it in src/i18n/index.ts.Localized fields in src/data/* — the compiler lists every
spot that needs the new translation.alternates.languages in src/app/[locale]/layout.tsx
(the CV route matches release assets by _XX_ locale marker).| What | Where |
|---|---|
| Role, socials, CV repo | src/data/profile.ts |
| Work experience | src/data/experience.ts |
| Projects (featured/other) | src/data/projects.ts |
| Skill areas | src/data/skills.ts |
| Education | src/data/education.ts |
| Certifications | src/data/certifications.ts |
| UI copy / translations | src/i18n/dictionaries/ |
Adding or removing a project, certification or job is a data-only change — append/remove an entry in the corresponding file.
docker compose up -d --build
# → http://localhost:3000
output: "standalone", final image runs as non-root
(nextjs, uid 1001) on node:22-alpine.restart: unless-stopped, no source mounted, secrets only via .env
(env_file is optional).wget -qO- http://127.0.0.1:3000/api/health.SITE_URL to the public URL.When a visitor lands on the site, a client beacon (once per browser session)
calls POST /api/visit; the server filters bots, applies a 30-minute per-IP
cooldown, enriches the request with geolocation (ipapi.co, with the Cloudflare
country header as fallback) and sends a Telegram message with location, device,
page and referrer. CV downloads trigger a second, shorter notification.
Requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID; without them the
endpoints are silent no-ops. The token never reaches the browser.
Every push to master runs .github/workflows/deploy.yml on the self-hosted
runner:
npm ci, lint, typecheck.SITE_URL as build-arg — set it to the public URL, since it is baked into
the static pages), smoke-tests it against /api/health, then pushes
ghcr.io/lucatomei/lucatomei-portfolio (:latest + :<sha>). No
repository secrets required: GHCR login uses the workflow’s own
GITHUB_TOKEN.Deployment is handled by Arcane on the MS01 (below); to update the running site after a push, pull the new image and redeploy the stack from Arcane.
The stack definition is in deploy/arcane-compose.yaml:
image from GHCR, port 3000 bound to localhost only, healthcheck,
restart: unless-stopped, environment via variables.
portfolio and paste the
compose file.TELEGRAM_BOT_TOKEN and
TELEGRAM_CHAT_ID; override them as stack variables only if they change
(SITE_URL is baked into the image at build time via the repository
variable).ghcr.io registry credentials in
Arcane (GitHub username + a PAT with read:packages); making the package
public avoids this.http://localhost:3000. Make sure the repository variable SITE_URL
matches the public hostname.master, wait for the pipeline, then pull &
redeploy the stack in Arcane (or enable Arcane’s auto-update for the stack).Manual deploy without Arcane:
docker compose up -d --build # from a checkout of this repo
GET /api/health returns {"status":"ok","uptime":<seconds>} with status 200.
latest release on MyCV, no
matching asset (*_IT_*.pdf / *_EN_*.pdf) and no dist/ copy; check the
Release CVs action on MyCV.TELEGRAM_BOT_TOKEN /
TELEGRAM_CHAT_ID, or you visited in the same browser session (one beacon
per session) or within the 30-minute per-IP cooldown.locale
cookie; clear it to re-trigger Accept-Language detection.<head> before
paint; if you fork the layout, keep that script first in <head>.docker build — next/font downloads Google fonts at
build time, so the build stage needs network access.