Skip to content
JJWrenPublic

About

Self-hostable developer portfolio: Blazor (.NET 10) + PostgreSQL. Markdown blog with OAuth comments, spam-hardened contact form, cookieless privacy-first analytics, hidden admin dashboard (moderation, inbox, stats, theming) — all personalized via env vars.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Portfolio

A self-hostable developer portfolio: home/about, projects carousel, markdown blog with OAuth-backed comments, a contact form with an admin inbox, and a hidden admin area — all personalized through environment variables so the same image works for anyone.

Built with ASP.NET Core Blazor (.NET 10) and PostgreSQL, shipped as a Docker Compose stack.

Features

  • Dark-first theme with a light-mode toggle, built on a five-color palette and locally bundled fonts (no CDN calls anywhere)
  • Blog: markdown posts with drafts, tags, reading time, header images, and locally bundled syntax highlighting; the list adds search, month, and tag filters with pagination; RSS feed at /feed.xml
  • Comments: visitors sign in with GitHub, Google, or Discord (any subset you configure); profiles carry a display name and avatar, or comments can be posted anonymously; comments run newest-first with admin-pinned comments on top; authors delete their own, admins moderate everything
  • Moderation: readers report comments; admins work a queue with notification badges, hide/delete controls, site-wide bans, and per-user messages — users track replies and report outcomes on their own messages and reports pages
  • Projects: admin-curated cards on a scroll-snap carousel with image upload, reordering, and visibility control
  • Image cropping: blog headers and project images go through a built-in 16:9 crop-box editor — zoom, drag, rule-of-thirds guides, and live previews of exactly what the card and hero will show
  • Contact form: messages always stored in the database, branded HTML email notification via SMTP on top (best-effort); spam defenses include a honeypot, per-IP rate limiting, a timing check, and sender-domain MX validation; suspicious-but-plausible messages (disposable domains, link-heavy bodies) are quarantined for admin review instead of emailed
  • Privacy-preserving analytics: server-side, cookieless, script-free page-view and engagement counts (project clicks, résumé downloads, contact submissions) with daily-rotating anonymous visitor hashes — raw IP/UA never stored, raw rows rolled up nightly into permanent daily aggregates and deleted after 90 days, DNT/GPC honored; viewable at /admin/stats
  • Hidden admin area at /admin — invisible and 404 for everyone not in ADMIN_EMAILS; sortable, filterable, paginated admin tables throughout
  • BJJ landing flavor (opt-in): setting SITE_FLAVOR=bjj swaps the landing page into a belt-themed layout — a hero game-plan chart, a rank bar, a Principles section, The road (a belt ladder above a dated era table), Now tiles, and a two-photo hero switch — all editable at /admin/site; leave the flag unset and the default landing page renders exactly as before
  • In-app site content: the landing page's hero heading, tagline, about, skills, and (under the BJJ flavor) hero eyebrow, game plan, belt caption and degrees, principles, eras, and Now tiles can all be overridden from the admin area — no redeploy needed
  • In-app theming: every palette color (brand, dark, and light) can be overridden from the admin area, with swatches, hex fields, a live landing-page preview for either mode, and WCAG contrast warnings
  • SEO: per-page OpenGraph meta, sitemap.xml, robots.txt; /healthz health endpoint

Quick start

git clone https://github.com/JJWren/Portfolio.git
cd Portfolio
cp .env.example .env
# Edit .env — see Configuration below
docker compose up -d

The site comes up on http://localhost:8080 (override with WEB_PORT). The database schema is created and migrated automatically on startup. Set SEED_DEMO_DATA=true for sample content on first run.

Configuration

Everything personal lives in .env — see .env.example for the full annotated list. The SITE_* values seed the landing page; once running, admins can override the hero heading, tagline, about text, and skills at /admin/site without touching .env (blank fields fall back to the .env values). Under the opt-in BJJ landing flavor (SITE_FLAVOR=bjj), the same admin page also carries fields for the hero eyebrow, game plan, belt caption, current belt and degrees, principles, eras, and Now tiles. The color palette can likewise be overridden at /admin/theme (blank fields fall back to the built-in colors).

Variable Required Purpose
SITE_OWNER_NAME ✅ Your name (hero, footer, and the nav logo's accessible label)
CONTACT_EMAIL ✅ Contact-form notifications + mailto links
POSTGRES_PASSWORD ✅ Database password (compose wires the connection string)
SITE_TITLE, SITE_TAGLINE, SITE_ABOUT, SITE_SKILLS Copy for the home page (\n splits paragraphs; skills comma-separated)
SITE_FLAVOR bjj (case-insensitive) opts into the belt-themed landing page below; blank or anything else renders the default landing page
SITE_HERO_EYEBROW Line shown above the hero heading (often your name and title); BJJ flavor only
SITE_GAME_PLAN Hero game-plan chart: exactly four term | reading | how lines joined with \n, or the chart stays hidden; BJJ flavor only
SITE_BELT_CAPTION, SITE_BELT_DEGREES Rank-bar caption and its degree stripes (0 to 6); the bar is hidden while the caption is blank; BJJ flavor only
SITE_CURRENT_BELT The belt the rank bar draws: white, blue, purple, brown or black; blank draws a black belt; BJJ flavor only
SITE_PRINCIPLES Principles section: 1 to 6 maxim | reading lines joined with \n; BJJ flavor only
SITE_ERAS The road's belt ladder and table: 1 to 12 date | belt | stripes | gym | location | role lines joined with \n (date is YYYY-MM-DD; belt is white, blue, purple, brown, or black); BJJ flavor only
SITE_NOW Now tiles: 1 to 8 label | value lines joined with \n; BJJ flavor only
SITE_META_DESCRIPTION Search/social snippet; blank falls back to SITE_TAGLINE
CONTACT_PHONE, LINKEDIN_URL, GITHUB_URL Contact & social links; GITHUB_URL drives the projects-page CTA
ADMIN_EMAILS Comma-separated; matching OAuth emails get the hidden admin area
OAUTH__<P>__CLIENTID / CLIENTSECRET Enable sign-in per provider (GITHUB, GOOGLE, DISCORD)
SMTP__HOST/PORT/USER/PASSWORD/FROM Email notifications; blank host = DB-only mode
PUBLIC_BASE_URL Canonical origin for canonical/og URLs, social cards, the feed, and the sitemap (e.g. https://you.example)
RESUME_FILE Path to a PDF served at /resume (with download counting); mount its folder read-write so the admin site-content page can replace it; unset = no résumé link
OWNER_PHOTO_FILE Path to the owner photo on the landing hero, served at /owner-photo (see also OWNER_PHOTO_FLIP_FILE below); mount its folder read-write so the admin site-content page can replace it; unset = photo-less hero
OWNER_PHOTO_ALT Alt text for the owner photo; defaults to Portrait of {SITE_OWNER_NAME}, admin-overridable
OWNER_PHOTO_FLIP_FILE Path to a second, "mat" portrait for the hero's two-photo switch, served at /owner-photo-flip; BJJ flavor only, and only once OWNER_PHOTO_FILE is also set; unset = the hero shows just the primary photo
OWNER_PHOTO_FLIP_ALT Alt text for the second photo; defaults to Portrait of {SITE_OWNER_NAME}, admin-overridable
SEED_DEMO_DATA true seeds sample posts/projects into empty tables
SECURITY_CSP_MODE Content-Security-Policy mode: enforce (default; also blank or unrecognized), report-only, or off; see Running behind a reverse proxy
TRUSTED_PROXIES Comma-separated IPs/CIDRs whose X-Forwarded-For is trusted for the connecting address rate limits and analytics use; blank trusts every peer; see Running behind a reverse proxy
WEB_BIND Host interface the published port binds to; blank publishes on every interface (0.0.0.0)

OAuth callback URLs

Register an app with each provider you enable, using these callback/redirect URLs (swap in your domain; http://localhost:8080/... works for local testing):

Provider Callback URL
GitHub https://your-domain/signin-github
Google https://your-domain/signin-google
Discord https://your-domain/signin-discord

The first time you sign in with an email listed in ADMIN_EMAILS, the Admin link appears in the nav. Role membership re-syncs on every sign-in.

Running behind a reverse proxy

The container serves plain HTTP on port 8080 and expects TLS to terminate at your proxy (Caddy, Traefik, nginx…). Forwarded headers (X-Forwarded-For / X-Forwarded-Proto) are honored so OAuth redirects build correct https:// URLs. Set PUBLIC_BASE_URL to your public origin.

By default every peer's X-Forwarded-For is honored, which is fine with no proxy in front but means a client that reaches the published port directly could forge its address. Once you add a proxy, set TRUSTED_PROXIES to its address or network (comma-separated IPs and/or CIDR blocks) so only its forwarded header is trusted; every other connection keeps its own address. A value that isn't blank but contains no address or network the app can parse stops startup with an error naming the entries it couldn't use, rather than silently falling back to trusting every peer. If the proxy shares the same Docker host, you can also bind the published port to loopback (WEB_BIND=127.0.0.1) so only that machine can reach it directly — the proxy still reaches the container over the Docker network either way.

Rate limits protect the auth endpoints (10 requests/minute), the RSS feed and sitemap (30/minute), and the résumé/project-link redirects (30/minute), all keyed by the connecting address (the trusted forwarded address once TRUSTED_PROXIES applies); a limited request gets 429 Too Many Requests with a Retry-After header and a short text body. Comment posting and report submission are limited per signed-in user, or per address for an anonymous comment, at 5 and 3 every 10 minutes; admins are exempt. Every limit lives in process memory and resets on restart — fine for this single-container app, but the numbers don't carry across a restart or a multi-instance deployment.

The app sets its own security headers on every response — X-Content-Type-Options, Referrer-Policy, X-Frame-Options, Permissions-Policy, Cross-Origin-Opener-Policy, and a Content-Security-Policy — so a self-hoster running with no reverse proxy in front is still fully covered. Your proxy may add its own security headers too; it doesn't need to remove or match the app's. Duplicates are harmless: a browser enforces every Content-Security-Policy header it receives (the stricter directive wins), and frame-ancestors supersedes X-Frame-Options wherever a response carries both.

If your setup does something the default policy doesn't expect (a sub-path deployment, an unusual proxy, a third-party embed), set SECURITY_CSP_MODE=report-only. The app then sends Content-Security-Policy-Report-Only instead of enforcing the policy — nothing is blocked, but your browser's console still logs what the policy would have stopped. Watch the console across your pages, adjust, then switch back to enforce (the default) once the console is quiet. SECURITY_CSP_MODE=off sends no Content-Security-Policy header at all, as a last-resort rollback; the other security headers above are always sent regardless of this setting.

Operations

  • Health: GET /healthz (also wired as the compose healthcheck) verifies the app + DB.
  • Data: named volumes — pgdata (database), uploads (images), dpkeys (cookie encryption keys). Back up pgdata and uploads.
  • Images: published to GHCR by CI on pushes to master (latest) and v* tags.

Development

docker compose up -d db            # local postgres
dotnet run --project src/Portfolio.Web
dotnet test                        # unit tests

Requires the .NET 10 SDK (pinned in global.json). EF migrations: dotnet ef migrations add <Name> --project src/Portfolio.Web.

License & sponsoring

MIT — see LICENSE. The site ships built-in /terms and /privacy pages that describe exactly what the software stores, auto-personalized from your .env.

If this project is useful to you, you can buy me a coffee ☕ — and self-hosters can point the footer's sponsor link at their own page via SPONSOR_URL / SPONSOR_TEXT (or leave it blank to hide it).

About

Self-hostable developer portfolio: Blazor (.NET 10) + PostgreSQL. Markdown blog with OAuth comments, spam-hardened contact form, cookieless privacy-first analytics, hidden admin dashboard (moderation, inbox, stats, theming) — all personalized via env vars.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages