Write once. Publish everywhere.
A self-hosted portfolio CMS that keeps your website, CV PDF (EN + DE), GitHub README, and LinkedIn bio in perfect sync — update one field and all four outputs regenerate automatically.
Live: highnet.at (Fly.io: highnet-cv.fly.dev)
highnet-cv/
├── apps/
│ ├── web/ # Angular 21 — public portfolio + /admin CMS
│ └── api/ # Express + TypeScript backend (serves API + Angular SPA)
├── packages/
│ ├── db/ # Drizzle ORM schema, migrations, seed
│ ├── shared/ # TypeScript types shared across apps
│ └── cv-templates/ # Handlebars HTML templates → Puppeteer A4 PDF
├── cli/ # npx highnet-cv CLI
└── fly/ # fly.toml + deploy.sh
| Layer | Tools |
|---|---|
| Frontend | Angular 21, TypeScript, SCSS |
| Backend | Express, TypeScript, Zod, Argon2id, JWT |
| Database | PostgreSQL 17, Drizzle ORM |
| Puppeteer + Handlebars HTML templates | |
| Integrations | Octokit (GitHub), clipboard (LinkedIn) |
| Infra | Docker Compose (local), Fly.io (production) |
| Monorepo | Turborepo + npm workspaces |
- Node.js 20+
- Docker Desktop (for local Postgres)
- Angular CLI:
npm i -g @angular/cli
git clone https://github.com/highnet/highnet-cv.git
cd highnet-cv
npm installcp .env.example .envEdit .env — required values for local dev:
DATABASE_URL=postgresql://portfolio:portfolio_dev@localhost:5432/portfolio_forge
JWT_SECRET=<any-random-32-char-hex>
ENCRYPTION_KEY=<any-random-32-char-hex>
PORT=3000
CORS_ORIGIN=http://localhost:4200Generate secrets:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"docker-compose up -dAll commands run from the project root:
npm run db:migrate # creates all tables via Drizzle
npm run db:seed # seeds with portfolio dataNote: The seed requires an existing user. First visit
http://localhost:4200/admin/loginto create your account, then rundb:seed.
npm run dev- API →
http://localhost:3000 - Angular dev server →
http://localhost:4200
| Route | Description |
|---|---|
/admin/login |
Login / first-run account setup |
/admin/dashboard |
Overview and stats |
/admin/profile |
Edit profile info |
/admin/experiences |
Work experience |
/admin/education |
Education history |
/admin/projects |
Projects |
/admin/skills |
Skills |
/admin/languages |
Languages |
/admin/cv |
CV preview + PDF download |
/admin/integrations |
GitHub README push + LinkedIn copy |
| Route | Description |
|---|---|
/ |
EN homepage |
/about |
EN about page with CV download |
/de |
DE homepage |
/de/about |
DE about page |
# Health
curl http://localhost:3000/api/health
# Public (no auth)
curl http://localhost:3000/api/public/profile
curl http://localhost:3000/api/public/projects
curl "http://localhost:3000/api/public/experiences?lang=de"
# CV PDF (cached — instant)
curl -o cv-en.pdf "http://localhost:3000/api/cv/file?lang=en"
curl -o cv-de.pdf "http://localhost:3000/api/cv/file?lang=de"
# CV PDF (on-the-fly generation — slow fallback)
curl -o cv-en.pdf "http://localhost:3000/api/cv/download?lang=en"The app deploys as a single Fly machine — Express serves the API and the Angular SPA as static files from the same container.
- flyctl installed and authenticated (
fly auth login)
node_modules/
**/node_modules/
dist/
**/dist/
.angular/
.git/
.env
uploads/
fly apps create highnet-cv --machinesfly postgres create \
--name highnet-cv-db \
--region fra \
--initial-cluster-size 1 \
--vm-size shared-cpu-1x \
--volume-size 10fly postgres attach highnet-cv-db --app highnet-cvThis automatically sets DATABASE_URL as a Fly secret.
fly secrets set --app highnet-cv \
JWT_SECRET=<your-secret> \
ENCRYPTION_KEY=<your-key>fly volumes create uploads --app highnet-cv --region fra --size 1 --yesfly deploy --app highnet-cv --remote-only --config fly/fly.tomlFly builds the Docker image remotely (Angular SPA + Express API in one container) and deploys it to Frankfurt.
Visit https://highnet.at/admin/login (or https://highnet-cv.fly.dev/admin/login) — the first visit shows a setup form to create your account.
fly ssh console -a highnet-cv --command "sh -c 'cd /app/packages/db && npx tsx src/seed.ts'"The seed is idempotent — safe to re-run to reset data.
fly certs create yourdomain.com --app highnet-cv
fly certs create www.yourdomain.com --app highnet-cvThen point your DNS A/AAAA records to the Fly.io IPs shown by fly certs show yourdomain.com.
Run from the project root:
| Script | What it does |
|---|---|
npm run dev |
Start API + Angular dev server in parallel |
npm run build |
Production build for all packages |
npm run db:generate |
Generate Drizzle migration files from schema |
npm run db:migrate |
Apply pending migrations to Postgres |
npm run db:seed |
Seed the database with portfolio data |
npm run lint |
ESLint across all workspaces |
Generate a new migration after changing the Drizzle schema:
npm run db:generateApply pending migrations:
# Local
npm run db:migrate
# Production (Fly.io)
fly ssh console -a highnet-cv -C "sh -c 'cd /app/packages/db && npx drizzle-kit migrate'"Reset and re-seed all portfolio data (idempotent — safe to re-run):
# Local
npm run db:seed
# Production (Fly.io) — runs the seed.ts that is currently deployed in the container
fly ssh console -a highnet-cv -C "node --import tsx/esm /app/packages/db/src/seed.ts"-
Edit the seed file — add a new entry to the
projectsarray inpackages/db/src/seed.ts. SetsortOrderto the next available integer andfeatured: true/false. -
Deploy — this ships the updated
seed.tsinside the Docker image:fly deploy --app highnet-cv --remote-only --config fly/fly.toml
-
Re-seed on production — run the seed from inside the deployed container (it has
DATABASE_URLset):fly ssh console -a highnet-cv -C "node --import tsx/esm /app/packages/db/src/seed.ts"The seed clears and re-inserts all experiences, projects, skills, and languages, so it's safe to re-run.
Important: always deploy first, then seed — the SSH command runs the seed.ts that's baked into the live container image. Seeding before deploying runs the old file and your new entry won't appear.
Why this trips you up: if you SSH in and seed before deploying, the container still has the previous image. Your local edits to
seed.tsare not visible inside the container until afterfly deploycompletes. The seed will report "✅ Seed complete!" with no errors — but the new data won't be there because it wasn't in the file that ran.
CVs are pre-generated and cached as PDF files. They auto-regenerate when you save data in the CMS. If needed, you can also trigger regeneration manually:
# Via admin dashboard — click "Regenerate CVs 🔄"
# Via API (authenticated)
curl -X POST https://highnet.at/api/cms/cv/regenerate \
-H "Cookie: token=<your-jwt-token>"
# Production SSH (requires Node script)
fly ssh console -a highnet-cv -C "sh -c 'cd /app && node --import tsx/esm -e \"import { regenerateCvCache } from \\\"./apps/api/src/services/pdf.service.js\\\"; regenerateCvCache(\\\"./cv-cache\\\").then(() => console.log(\\\"done\\\"))\"'"# Full rebuild + deploy
fly deploy --config fly/fly.toml --remote-only
# Check app status
fly status --app highnet-cv
# View live logs
fly logs --app highnet-cv| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string |
JWT_SECRET |
Yes | 32+ char secret for JWT signing |
ENCRYPTION_KEY |
Yes | 32-byte hex key for GitHub token encryption |
PORT |
No | API port (default: 3000) |
CORS_ORIGIN |
No | Allowed CORS origin (default: http://localhost:4200) |
UPLOAD_DIR |
No | Profile photo upload directory (default: ./uploads) |
CV_CACHE_DIR |
No | Cached CV PDF directory (default: ./cv-cache) |
NODE_ENV |
No | Set to production to serve Angular SPA from Express |
apps/
web/src/app/
core/
services/ portfolio.service.ts · auth.service.ts · i18n.service.ts
guards/ auth.guard.ts
admin/
login/ /admin/login
dashboard/ /admin/dashboard
cv/ /admin/cv
integrations/ /admin/integrations
cms/
profile/ /admin/profile
experiences/ /admin/experiences
education/ /admin/education
projects/ /admin/projects
skills/ /admin/skills
languages/ /admin/languages
homepage/ / and /de
about/ /about and /de/about
api/src/
routes/ auth · public · cms · cv · integrations
services/ pdf.service.ts (Puppeteer)
middleware/ auth.middleware.ts · error.middleware.ts
lib/ jwt.ts · crypto.ts
packages/
db/src/
schema/ users · profiles · experiences · education
projects · skills · languages · integrations
migrations/
seed.ts
cv-templates/ en/template.html · de/template.html (Handlebars → A4)
shared/ TypeScript types + DTOs
See LICENSE.