Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Portfolio

A self-hosted personal portfolio site with an owner-only admin area. The public site shows a profile picture, headline, bio, social links, a resume viewer and an optional GitHub project showcase. The admin area lets the owner change all of that from the browser, with no code edits and no redeploys.

The project is split into an Angular front end and a Python (FastAPI) back end, and ships as a single Docker image that serves both.

Table of contents

Subsections under Technical design, Running the project and Deployment are collapsed; click a heading's arrow to expand it.

Features

Public site

  • Profile picture, headline and bio with the owner's chosen font, weight and size.
  • Social and contact links with Font Awesome icons and per-link hover colours.
  • Resume rendered inside the page on desktop and mobile, with clickable links and a download button. Hidden entirely until a resume is published.
  • Optional "Projects" section that embeds the owner's GitHub repositories.
  • Dark mode (default) and light mode with a navbar switch; the choice is remembered per browser.
  • Responsive layout down to phone width; page titles follow the site title set by the owner.

Admin area (reachable only by URL, never linked from the site)

  • Exactly one owner account. While none exists, a Register link in the navbar and a button on the sign-in page lead to the registration form; both disappear as soon as the account is created.
  • Profile: headline, bio, display name, navbar brand, browser title, footer text.
  • Picture: upload with a circular crop and zoom dialog, or delete to fall back to the default image. Animated GIFs keep their animation: the file is stored untouched and the crop is applied with CSS.
  • Bio typography: font (Open Sans, Palanquin, system), weight and size with live preview.
  • Links: add, edit, reorder, hide/show, delete, with icon presets.
  • Resume versions: upload PDFs, choose which one is displayed, rename, open, delete.
  • GitHub showcase: enable, pick repositories, or show all sorted by stars/date/name.
  • Password change. Toast notifications that fade on their own.

Operations

  • One Docker image, one volume for all data, one port.
  • Interactive API documentation (Swagger UI) at /docs, reference docs at /redoc.
  • Automatic SQLite schema upgrades when new columns are introduced.

Technical design

Architecture

flowchart LR
    subgraph Browser
        A[Angular app<br/>public pages + admin]
    end
    subgraph "Python process (uvicorn)"
        B[FastAPI]
        C[Static: built Angular app]
        D[Static: /uploads]
    end
    E[(SQLite<br/>portfolio.db)]
    F[GitHub REST / GraphQL API]

    A -- "JSON over /api/*" --> B
    A -- "HTML, JS, CSS, fonts" --> C
    A -- "pictures, PDFs" --> D
    B --> E
    B -- "cached 10 min" --> F
Loading

The Angular app is a single-page application. In production the Python process serves it as static files together with the API and the uploaded files, so everything is on one origin and no CORS or second web server is needed. During development the Angular dev server proxies /api and /uploads to the Python process (see frontend/proxy.conf.json).

Request flow

  1. The browser loads index.html; an inline script applies the saved theme before first paint.
  2. The app calls GET /api/public/portfolio once and caches the result in SiteService. The navbar, home page, footer, resume page and title strategy all read from that one payload.
  3. Admin pages call the authenticated endpoints and then refresh the cached payload, so the public parts of the page reflect changes immediately.
  4. Auth is a bearer JWT stored in the browser; an HTTP interceptor attaches it to /api calls and signs the user out on a 401.

Data model

Table Purpose
users The single owner account (username, bcrypt password hash).
profile One row: display name, headline, bio, typography, brand, titles, avatar_url, active_resume_id.
links Social links: label, URL, icon class, hover colour, position, visible flag.
resumes Uploaded resume versions: label, URL, size, upload time. The profile points at the displayed one.
github_settings One row: enabled, username, mode (selected / all / pinned), picked repos, sorting, limit.

Tables are created on startup. When a newer version adds columns, they are added to an existing database automatically (backend/app/database.py).

API surface

Area Endpoints Auth
Public GET /api/public/portfolio, GET /api/health no
Auth GET /api/auth/registration, POST /api/auth/register, POST /api/auth/login, GET /api/auth/me, PUT /api/auth/password mixed
Profile GET /api/profile, PUT /api/profile, POST/DELETE /api/profile/avatar GET public, rest yes
Resumes GET /api/resumes, POST /api/resumes, PUT /api/resumes/{id}/display, PUT /api/resumes/{id}, DELETE /api/resumes/{id} yes
Links GET /api/links, POST /api/links, PUT /api/links/{id}, DELETE /api/links/{id}, PUT /api/links/reorder GET public, rest yes
GitHub GET /api/github/showcase (public), GET/PUT /api/github/settings, GET /api/github/repos, POST /api/github/cache/clear mixed

Open /docs on a running instance for the interactive version with request and response schemas.

Authentication and the single-owner rule

  • Passwords are hashed with bcrypt. Logins return a signed JWT (HS256) that expires after ACCESS_TOKEN_EXPIRE_MINUTES.
  • MAX_ACCOUNTS (default 1) caps the number of accounts. POST /api/auth/register works only while fewer accounts exist, then returns 403. The hidden page /admin/register uses it.
  • Alternatively, set ADMIN_PASSWORD before the first start and the account is created for you.
  • While no account exists, the navbar shows a Register link and the sign-in page a "Create the owner account" button. Once the account exists, both disappear and nothing on the public site links to /admin/login or /admin/register; they are reached by URL. On a public server, create the account right away (or set ADMIN_PASSWORD) so the registration form is never left open.

File storage

Uploads are validated by their first bytes (not by file extension), limited by MAX_UPLOAD_MB, and written under UPLOAD_DIR:

  • uploads/avatar/ holds the current profile picture: a 512×512 JPEG produced by the cropper, or the original file for animated GIFs (the crop is then stored as zoom and offset on the profile). Uploading again replaces it; deleting removes it.
  • uploads/resume/ holds every resume version. Deleting a version removes its file.

The backend serves this directory at /uploads/.... In Docker, UPLOAD_DIR and the database live on the portfolio-data volume, so rebuilding the image never touches them.

Theming and fonts

All colours are CSS custom properties in frontend/src/styles.scss, defined once for [data-theme="dark"] (the original site palette: #1f2937 background, teal #008080 accent) and once for [data-theme="light"]. ThemeService flips the attribute and persists the choice in localStorage. Open Sans and Palanquin are self-hosted as woff2 files in frontend/public/fonts, so the site looks the same on every operating system.

Resume rendering

The resume page draws each PDF page onto a canvas with pdf.js, which works on Android and iOS where inline PDF frames do not. The PDF's link annotations are overlaid as real anchors so links stay clickable. pdf.js is pinned to the 5.4 line on purpose: newer releases depend on a JavaScript API that many current browsers still lack.

GitHub showcase

backend/app/services/github_client.py reads public repositories through the GitHub REST API (no token needed) and pinned repositories through GraphQL (token needed). Responses are cached for ten minutes. If GitHub is unreachable or rate limited, the public endpoint still succeeds and the section simply shows nothing, so the site never goes down because of GitHub.

Technology stack

Layer Technology
Front end Angular 22, TypeScript 5.9, standalone components and signals, SCSS, Angular Router (lazy routes)
UI assets Font Awesome Free 6, Open Sans and Palanquin (self-hosted woff2), pdf.js 5.4
Back end Python 3.11+, FastAPI, SQLModel on SQLAlchemy 2, SQLite, pydantic-settings, uvicorn
Security bcrypt password hashing, PyJWT bearer tokens, content-sniffed uploads
Integrations GitHub REST and GraphQL via httpx
Tooling vitest (front-end unit tests), pytest (back-end tests), Docker multi-stage build, Docker Compose

Project structure

portfolio/
├── Dockerfile              # multi-stage: builds the Angular app, then the Python image serving both
├── docker-compose.yml      # one service, one volume, port 8080
├── frontend/
│   ├── public/             # static files copied to the site root (default picture, favicons, fonts)
│   ├── proxy.conf.json     # dev-server proxy to the backend
│   └── src/app/
│       ├── core/           # models, ApiService, AuthService, interceptor, guard, ThemeService, fonts, title strategy
│       ├── shared/         # navbar, footer, theme toggle, repo card, toasts, SiteService
│       └── pages/
│           ├── home/       # public landing page
│           ├── resume/     # pdf.js viewer
│           └── admin/      # login, register, dashboard and its panels
└── backend/
    ├── app/
    │   ├── main.py         # app factory, CORS, Swagger metadata, static mounts
    │   ├── config.py       # settings from environment / .env
    │   ├── database.py     # engine, table creation, column migration
    │   ├── models.py       # SQLModel tables
    │   ├── schemas.py      # request / response models
    │   ├── security.py     # bcrypt + JWT helpers
    │   ├── deps.py         # auth dependency, singleton-row helpers
    │   ├── seed.py         # bootstrap account and seed content
    │   ├── routers/        # auth, profile, resumes, links, github, public
    │   └── services/       # github_client
    ├── tests/              # pytest suite
    └── .env.example        # documented configuration template

Prerequisites

Way of running Needs
Docker Compose Docker Engine 24+ with the Compose plugin (or Docker Desktop)
Development / without Docker Node.js 22.22.3+ or 24.15+ (Node 24 LTS recommended), npm; Python 3.11+

Configuration

Every setting is an environment variable. Without Docker, copy backend/.env.example to backend/.env and edit it. With Docker, edit the environment: block in docker-compose.yml or use a .env file next to it.

Variable Default Meaning
SECRET_KEY dev-secret-change-me JWT signing key. Change it. Generate: python -c "import secrets; print(secrets.token_urlsafe(48))"
ADMIN_USERNAME admin Username of the bootstrap account (only used with ADMIN_PASSWORD).
ADMIN_PASSWORD empty If set, the owner account is created on first start. If empty, register once at /admin/register.
MAX_ACCOUNTS 1 Hard cap on accounts.
ACCESS_TOKEN_EXPIRE_MINUTES 720 Login session length.
DATABASE_URL sqlite:///./data/portfolio.db SQLAlchemy URL. Docker uses sqlite:////data/portfolio.db.
UPLOAD_DIR ./uploads Where pictures and resumes are stored. Docker uses /data/uploads.
MAX_UPLOAD_MB 5 Upload size limit.
CORS_ORIGINS http://localhost:4200,http://127.0.0.1:4200 Only needed when the front end is served from a different origin.
GITHUB_TOKEN empty Optional. Raises GitHub's rate limit and enables "pinned" mode.
FRONTEND_DIST empty Path to the built Angular app; when set, the backend serves it.

Running the project

Option A: Docker Compose (recommended)

Builds the front end, installs the back end and starts one container that serves everything on http://localhost:8080.

cd portfolio
# edit SECRET_KEY (and optionally ADMIN_PASSWORD) in docker-compose.yml first
docker compose up --build -d

Option B: two development servers

Best while changing code: both servers reload on save.

Terminal 1, back end on port 8000:

cd portfolio/backend
python -m venv .venv
# Linux / macOS:            source .venv/bin/activate
# Windows PowerShell:       .\.venv\Scripts\Activate.ps1
pip install -r requirements-dev.txt
cp .env.example .env          # Windows: copy .env.example .env
uvicorn app.main:app --reload --port 8000

Terminal 2, front end on port 4200 (proxies /api and /uploads to port 8000):

cd portfolio/frontend
npm install
npm start

Open http://localhost:4200. API docs are at http://localhost:8000/docs.

Option C: single process without Docker

Build the front end once and let the Python process serve it. This is what the Docker image does, and it is the basis for the non-Docker deployments below.

cd portfolio/frontend
npm ci
npm run build                 # → frontend/dist/portfolio/browser

cd ../backend
python -m venv .venv && source .venv/bin/activate      # Windows: .\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
cp .env.example .env
# in .env set: FRONTEND_DIST=../frontend/dist/portfolio/browser
uvicorn app.main:app --host 0.0.0.0 --port 8000

Open http://localhost:8000.

First-time setup

  1. Create the owner account. Either set ADMIN_PASSWORD before the first start, or use the Register link in the navbar (also offered on the sign-in page) while no account exists. After that the link disappears and /admin/register reports that registration is closed.
  2. Sign in at /admin/login. A "Manage" link then appears in the navbar.
  3. Profile tab: upload and crop your picture, edit the headline and bio, pick the bio font, set the navbar brand, browser title and footer text. Upload your resume PDF; it becomes the displayed version and the resume button and navbar link appear on the public site.
  4. Links tab: adjust the seeded links, add new ones, reorder or hide them.
  5. GitHub showcase tab: enable it, enter your GitHub username, load your repositories and pick the ones to show (or switch to "all" with sorting and a limit). Save.
  6. Account tab: change your password.

The database is seeded with the original site's headline, bio and four links so nothing starts empty.

Deployment

All deployments boil down to: run the Python process (which serves the site, the API and the uploads) and put a reverse proxy with HTTPS in front of it. Docker is the least effort on every platform; the native instructions are for machines where Docker is unwanted.

Linux server

With Docker (Debian/Ubuntu shown):

# install Docker Engine + Compose plugin (official convenience script)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER && newgrp docker

# get the project onto the server (git clone, scp, or unzip), then:
cd portfolio
nano docker-compose.yml        # set SECRET_KEY, optionally ADMIN_PASSWORD
docker compose up --build -d

The container restarts with Docker unless stopped; add restart: unless-stopped under the service in docker-compose.yml to also survive reboots.

Without Docker (systemd service):

  1. Install prerequisites: sudo apt install python3 python3-venv nodejs npm (use NodeSource or nvm if the distro's Node is older than 22.22.3).

  2. Place the project in /opt/portfolio and follow Option C to build the front end and create the virtualenv. Put real values in backend/.env.

  3. Create /etc/systemd/system/portfolio.service:

    [Unit]
    Description=Yousif Portfolio
    After=network.target
    
    [Service]
    User=www-data
    WorkingDirectory=/opt/portfolio/backend
    EnvironmentFile=/opt/portfolio/backend/.env
    ExecStart=/opt/portfolio/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
    Restart=on-failure
    
    [Install]
    WantedBy=multi-user.target
  4. sudo chown -R www-data /opt/portfolio/backend/{data,uploads} (create the folders first), then sudo systemctl enable --now portfolio and check systemctl status portfolio.

  5. Add the reverse proxy from Reverse proxy and HTTPS.

macOS

With Docker Desktop: install Docker Desktop for Mac (Apple silicon or Intel), then run the same docker compose up --build -d from the project folder. Docker Desktop must be running for the container to be up; enable "Start Docker Desktop when you sign in" for a machine that stays on.

Without Docker: install Node and Python with Homebrew (brew install node python), follow Option C, and keep the process alive with a launchd agent. Save this as ~/Library/LaunchAgents/com.yousif.portfolio.plist (adjust paths):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>com.yousif.portfolio</string>
  <key>WorkingDirectory</key><string>/Users/you/portfolio/backend</string>
  <key>ProgramArguments</key><array>
    <string>/Users/you/portfolio/backend/.venv/bin/uvicorn</string>
    <string>app.main:app</string><string>--host</string><string>127.0.0.1</string><string>--port</string><string>8000</string>
  </array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>StandardOutPath</key><string>/tmp/portfolio.log</string>
  <key>StandardErrorPath</key><string>/tmp/portfolio.err</string>
</dict></plist>

Load it with launchctl load ~/Library/LaunchAgents/com.yousif.portfolio.plist. The backend reads backend/.env from its working directory, so no environment block is needed in the plist.

Windows

With Docker Desktop (recommended): install Docker Desktop for Windows with the WSL 2 backend, then in PowerShell:

cd D:\devStuff\portfolio
notepad docker-compose.yml     # set SECRET_KEY, optionally ADMIN_PASSWORD
docker compose up --build -d

Inside WSL 2 without Docker Desktop: open your Ubuntu distribution and follow the Linux server instructions there. Ports published from WSL are reachable from Windows at localhost.

Natively on Windows: install Node 24 LTS and Python 3.12 from their installers (tick "Add to PATH"), then follow Option C in PowerShell. To run it as a background service, either:

  • create a Task Scheduler task "At startup" running D:\devStuff\portfolio\backend\.venv\Scripts\uvicorn.exe app.main:app --host 127.0.0.1 --port 8000 with "Start in" set to the backend folder, or
  • install it as a Windows service with NSSM: nssm install portfolio, pointing at the same executable, arguments and startup directory.

Open port 8000 (or the reverse proxy's port) in Windows Defender Firewall if other devices should reach it.

Raspberry Pi

Works on a Raspberry Pi 4 or 5 running the 64-bit Raspberry Pi OS (or Ubuntu Server arm64). Both base images used by the Dockerfile (node:24-alpine, python:3.12-slim) publish arm64 variants, so the same Compose file builds unchanged.

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER && newgrp docker
cd portfolio
nano docker-compose.yml        # set SECRET_KEY, optionally ADMIN_PASSWORD; add restart: unless-stopped
docker compose up --build -d

Notes for the Pi:

  • The first build compiles the Angular app on the Pi and takes several minutes; later builds are faster thanks to layer caching. A Pi with 2 GB RAM or less may need a swap file during the build (sudo dphys-swapfile swapoff; edit CONF_SWAPSIZE=2048 in /etc/dphys-swapfile; sudo dphys-swapfile setup; sudo dphys-swapfile swapon).
  • To avoid building on the Pi at all, build the image on a PC with docker buildx build --platform linux/arm64 -t portfolio:latest --load ., export it with docker save portfolio:latest | gzip > portfolio.tar.gz, copy it over and docker load it. Then set image: portfolio:latest in place of the build: block.
  • The 32-bit OS (armv7) is not recommended: the Node image has no armv7 tag.
  • Use an SSD or a good SD card; SQLite and uploads live on the Docker volume.
  • Without Docker, the Linux server systemd instructions apply; install Node 24 from NodeSource's arm64 packages.

Reverse proxy and HTTPS

Put nginx (or Caddy) in front of port 8000 (native) or 8080 (Docker) and terminate TLS there. Example nginx server block for yousifzito.com:

server {
    listen 80;
    server_name yousifzito.com www.yousifzito.com;

    client_max_body_size 10m;          # uploads (MAX_UPLOAD_MB is 5 by default)

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Then obtain a certificate with Certbot: sudo apt install certbot python3-certbot-nginx and sudo certbot --nginx -d yousifzito.com -d www.yousifzito.com. Certbot rewrites the block for HTTPS and renews automatically.

With Caddy the whole thing is two lines in a Caddyfile, HTTPS included:

yousifzito.com {
    reverse_proxy 127.0.0.1:8080
}

Point the domain's DNS A record (and AAAA if you have IPv6) at the server. If the server sits at home, forward ports 80 and 443 on the router and consider a dynamic DNS service.

Updating

Docker:

cd portfolio
# replace the source with the new version (git pull, or extract the new zip over it)
docker compose up --build -d

Native: rebuild the front end (npm ci && npm run build), update Python dependencies (pip install -r requirements.txt) and restart the service. Database columns added by the new version are created automatically on startup. Your account, pictures and resumes are never touched by an update.

Backup and restore

Everything worth keeping is the SQLite file and the uploads folder.

Docker (volume portfolio-data):

# backup
docker run --rm -v portfolio_portfolio-data:/data -v "$PWD":/backup alpine \
  tar czf /backup/portfolio-backup.tar.gz -C /data .
# restore
docker run --rm -v portfolio_portfolio-data:/data -v "$PWD":/backup alpine \
  sh -c "cd /data && tar xzf /backup/portfolio-backup.tar.gz"

(docker volume ls shows the exact volume name; it is prefixed with the Compose project name.)

Native: copy backend/data/ and backend/uploads/.

Testing

# back end
cd backend && pip install -r requirements-dev.txt && pytest

# front end
cd frontend && npm test          # vitest
npm run build                    # production build, also type-checks templates

Troubleshooting

Symptom Cause and fix
"Cannot reach the backend" in the admin area The Python process is not running, or the dev proxy target is wrong. Check docker compose logs or the uvicorn terminal.
Registration page says it is closed An account already exists. Sign in at /admin/login, or reset the data volume for a fresh start.
Forgot the password Stop the service, delete the database (backend/data/portfolio.db or the volume), start again, register once more. Uploads are separate and survive.
GitHub section empty, admin shows a GitHub error GitHub is unreachable or rate limited (60 requests/hour without a token). Set GITHUB_TOKEN, or wait; the site itself keeps working.
Upload rejected with 415 The file is not really a PNG/JPEG/WEBP/GIF (pictures) or PDF (resume). The check reads the file's bytes, not its name.
Upload rejected with 413 Larger than MAX_UPLOAD_MB. Raise it, and client_max_body_size in nginx if used.
Angular CLI complains about the Node version Angular 22 needs Node 22.22.3+ or 24.15+. Install Node 24 LTS.
Old page after an update Hard refresh (Ctrl+F5). Built files are content-hashed, so this is rare.

Licenses of bundled assets

  • Open Sans and Palanquin fonts: SIL Open Font License 1.1.
  • Font Awesome Free: icons CC BY 4.0, fonts SIL OFL 1.1, code MIT.
  • pdf.js: Apache License 2.0.

About

Self-hosted personal portfolio with an owner-only admin area. Angular 22 + FastAPI + SQLite in one Docker image. Edit bio, picture, links, resume and GitHub showcase from the browser. Runs on a Raspberry Pi.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages