LucasMac

lucatomei — personal portfolio

Personal portfolio of Luca Tomei, software engineer in Rome. Bilingual (IT/EN), dark/light theme, self-hosted with Docker.

Stack

Architecture

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.

Development

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

Environment

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

CV downloads

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):

  1. Rolling latest release of the public repo LucaTomei/MyCV (asset name containing _IT_ / _EN_, .pdf).
  2. dist/ folder of MyCV (compiled copies committed by its CI).
  3. Redirect to the 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.

i18n

Adding a language (e.g. fr):

  1. Add "fr" to locales in src/i18n/config.ts.
  2. Create src/i18n/dictionaries/fr.ts (export const fr: Dictionary = {...}) and register it in src/i18n/index.ts.
  3. Extend the Localized fields in src/data/* — the compiler lists every spot that needs the new translation.
  4. Add the locale to alternates.languages in src/app/[locale]/layout.tsx (the CV route matches release assets by _XX_ locale marker).

Editing content

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 (production)

docker compose up -d --build
# → http://localhost:3000

Visitor notifications (Telegram)

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.

CI/CD (self-hosted runner → GHCR)

Every push to master runs .github/workflows/deploy.yml on the self-hosted runner:

  1. checksnpm ci, lint, typecheck.
  2. package — builds the Docker image (passing the repository variable 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.

Deploy on MS01 with 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.

  1. In Arcane create a new project/stack named portfolio and paste the compose file.
  2. The compose file ships with working defaults for 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).
  3. If the GHCR package is private, add ghcr.io registry credentials in Arcane (GitHub username + a PAT with read:packages); making the package public avoids this.
  4. Deploy, then point the cloudflared tunnel on the host at http://localhost:3000. Make sure the repository variable SITE_URL matches the public hostname.
  5. To ship an update: push to 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

Health

GET /api/health returns {"status":"ok","uptime":<seconds>} with status 200.

Troubleshooting