Personal portfolio site built with Next.js 16. Bilingual (EN/DE), four switchable design themes with dark/light variants, self-hosted via Docker and Traefik.
- Four design themes (Atelier, Iris, Manifesto, Cobalt), each with dark and light variants
- Bilingual content (EN/DE) with path-based routing via next-intl
- Per-theme hero animations built with Framer Motion
- Project pages authored in MDX
- Shareable URLs with theme preference via query params
- Automated deployment: push to
main-> Docker image built and deployed via GitHub Actions
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) + TypeScript |
| Styling | Tailwind CSS v4 |
| Animations | Framer Motion |
| i18n | next-intl (EN/DE, path-based) |
| Theming | next-themes + custom CSS custom properties |
| Content | MDX via next-mdx-remote |
| CI/CD | GitHub Actions -> GHCR -> Docker + Traefik |
Theming has two independent layers: dark/light mode (managed by next-themes, stored as a class on <html>) and design themes (four palettes applied via data-theme, stored in localStorage). An inline script in <head> reads both before hydration to prevent flash.
All site content lives in content/{locale}/ — JSON files for structured data (experience, education, publications) and MDX for project detail pages. Content changes don't require touching application code.
pnpm install
pnpm dev # http://localhost:3000
pnpm build # Production build
pnpm tsc --noEmit # Type-checkAll content lives in content/{locale}/ and can be edited without touching application code:
content/{locale}/data/experience.json— Work experiencecontent/{locale}/data/education.json— Educationcontent/{locale}/data/certificates.json— Certificatescontent/{locale}/data/about.json— Bio and intro textcontent/{locale}/data/publications.json— Publicationscontent/{locale}/projects/*.mdx— Project detail pages
Where {locale} is en or de. English is the source of truth; German falls back to English where translations are missing.
The site runs in Docker behind Traefik, deployed via GitHub Actions. Pushing to main builds a Docker image, pushes it to GHCR, and SSHes into the server to pull and restart the container.
- Install Docker and Docker Compose on the server
- Add the deployment public key to
~/.ssh/authorized_keys - Create a
docker-compose.ymlon the server:
portfolio:
image: ghcr.io/conava/portfolio-website:latest
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.portfolio.rule=Host(`marlonkranz.com`)"
- "traefik.http.routers.portfolio.entrypoints=websecure"
- "traefik.http.routers.portfolio.tls.certresolver=letsencrypt"
- "traefik.http.services.portfolio.loadbalancer.server.port=3000"
networks:
- traefikAdd these in Settings > Secrets and variables > Actions:
| Secret | Value |
|---|---|
DEPLOY_HOST |
Server IP or hostname |
DEPLOY_USER |
SSH user |
DEPLOY_KEY |
Private SSH key (unencrypted, contents of ~/.ssh/deploy_key) |
DEPLOY_PATH |
Absolute path to docker-compose.yml on the server |
Edit MDX/JSON files and push. The pipeline runs automatically.