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.
Subsections under Technical design, Running the project and Deployment are collapsed; click a heading's arrow to expand it.
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.
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
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).
- The browser loads
index.html; an inline script applies the saved theme before first paint. - The app calls
GET /api/public/portfolioonce and caches the result inSiteService. The navbar, home page, footer, resume page and title strategy all read from that one payload. - Admin pages call the authenticated endpoints and then refresh the cached payload, so the public parts of the page reflect changes immediately.
- Auth is a bearer JWT stored in the browser; an HTTP interceptor attaches it to
/apicalls and signs the user out on a 401.
| 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).
| 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.
- 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/registerworks only while fewer accounts exist, then returns 403. The hidden page/admin/registeruses it.- Alternatively, set
ADMIN_PASSWORDbefore 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/loginor/admin/register; they are reached by URL. On a public server, create the account right away (or setADMIN_PASSWORD) so the registration form is never left open.
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.
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.
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.
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.
| 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 |
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
| 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+ |
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. |
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- Site: http://localhost:8080
- API docs: http://localhost:8080/docs
- Logs:
docker compose logs -f - Stop:
docker compose down(data stays in theportfolio-datavolume)
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 8000Terminal 2, front end on port 4200 (proxies /api and /uploads to port 8000):
cd portfolio/frontend
npm install
npm startOpen http://localhost:4200. API docs are at http://localhost:8000/docs.
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 8000Open http://localhost:8000.
- Create the owner account. Either set
ADMIN_PASSWORDbefore 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/registerreports that registration is closed. - Sign in at
/admin/login. A "Manage" link then appears in the navbar. - 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.
- Links tab: adjust the seeded links, add new ones, reorder or hide them.
- 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.
- Account tab: change your password.
The database is seeded with the original site's headline, bio and four links so nothing starts empty.
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.
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 -dThe 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):
-
Install prerequisites:
sudo apt install python3 python3-venv nodejs npm(use NodeSource ornvmif the distro's Node is older than 22.22.3). -
Place the project in
/opt/portfolioand follow Option C to build the front end and create the virtualenv. Put real values inbackend/.env. -
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
-
sudo chown -R www-data /opt/portfolio/backend/{data,uploads}(create the folders first), thensudo systemctl enable --now portfolioand checksystemctl status portfolio. -
Add the reverse proxy from Reverse proxy and HTTPS.
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.
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 -dInside 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 8000with "Start in" set to thebackendfolder, 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.
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 -dNotes 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 withdocker save portfolio:latest | gzip > portfolio.tar.gz, copy it over anddocker loadit. Then setimage: portfolio:latestin place of thebuild: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.
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.
Docker:
cd portfolio
# replace the source with the new version (git pull, or extract the new zip over it)
docker compose up --build -dNative: 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.
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/.
# 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| 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. |
- 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.