Deployed application:
This project is a web application built with FastAPI that allows users to create and manage a personal e-portfolio.
The platform provides both public and private features:
- User registration
- Secure authentication
- Personal profile management
- Experience management
- Education management
- Public portfolio publication
- Portfolio search system
- Portfolio pagination system
The application follows a classic web architecture with a FastAPI backend, Jinja2 templates for rendering, and PostgreSQL for data persistence in Docker. SQLite remains available as a fallback for local runs outside Docker.
- Python 3.12
- FastAPI
- SQLModel
- PostgreSQL (Docker)
- SQLite (local fallback)
- JWT Authentication
- HTTP-only Cookies
- Argon2 Password Hashing
- HTML
- CSS
- Jinja2 Templates
- Docker
- Docker Compose
- Git
- Cron-based Continuous Deployment
- Public homepage listing all available portfolios
- Search portfolios by name
- Pagination system
- Public portfolio pages accessible without authentication
- User registration
- Secure login/logout
- JWT-based authentication
- HTTP-only authentication cookies
- Password hashing using Argon2
- Protected routes
- Session invalidation after logout
- Personal profile page
- Automatic age calculation from birth date
- Display personal information
- Create experiences
- Read experiences
- Update experiences
- Delete experiences
- Create education entries
- Read education entries
- Update education entries
- Delete education entries
- Data ownership system
- User isolation
- Protected user resources
- Users cannot modify another user's data
| Route | Description |
|---|---|
/ |
Public homepage |
/search |
Portfolio search |
/login |
Login page |
/create_user |
User registration |
/profil |
Private dashboard |
/portfolio/{id} |
Public portfolio |
The application uses a relational database implemented with SQLModel.
| Field | Type |
|---|---|
| id | Primary Key |
| name | String |
| first_name | String |
| birth_date | Date |
| String | |
| phone | String |
| hashed_password | String |
| Field | Type |
|---|---|
| id | Primary Key |
| title | String |
| company | String |
| date_start | Date |
| date_end | Date |
| description | String |
| user_id | Foreign Key |
| Field | Type |
|---|---|
| id | Primary Key |
| school_name | String |
| major | String |
| date_start | Date |
| date_end | Date |
| description | String |
| user_id | Foreign Key |
A user can own multiple professional experiences.
A user can own multiple education entries.
Relationship rules:
- One experience belongs to one user.
- One education entry belongs to one user.
- Deleting a user removes access to their associated records.
.
├── core/
│ ├── database_2.py
│ └── security.py
│
├── routers/
│ ├── auth.py
│ ├── education.py
│ └── experience.py
│ └── user.py
│
├── schemas/
│ ├── User.py
│ ├── Experience.py
│ └── Education.py
│
├── static/
│ ├── base.css
│ ├── home.css
│ ├── login_style.css
│ ├── create_user.css
│ └── public_profile.css
│ └── login.css
│
├── templates/
│ ├── home.html
│ ├── login.html
│ ├── profil.html
│ ├── experience.html
│ ├── education.html
│ └── public_profile.html
│ └── create_user.html
│
├── docker-compose.yml
├── dockerfile
├── requirements.txt
├── seed.py
└── main.py
git clone <repository-url>
cd e-portfolioCreate the local environment file and replace every placeholder secret:
Copy-Item .env.example .envKeep the development values below when accessing the application over local HTTP:
APP_ENV=development
COOKIE_SECURE=false
SEED_DEMO_DATA=truedocker compose up -d --build
docker compose psFollow the application logs with:
docker compose logs -f eportfolioThe application is available at http://127.0.0.1:8000 and pgAdmin at
http://127.0.0.1:5050. Docker Compose is the reference full-stack development
workflow for this project; running fastapi dev directly on the host is not
required. The isolated automated test suite runs directly in Python as
described below.
The automated test suite uses a temporary SQLite database and does not read or
modify the developer's database.db. Run it in its own Python 3.12 virtual
environment so packages installed for another application cannot conflict with
this project's pinned dependencies:
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
python -m pytestpytest.ini enables branch coverage for the application code and enforces a
minimum of 95%. The terminal report lists uncovered lines. GitHub
Actions runs the same command for every push and pull request, after checking
that the application imports and that Alembic can build a schema matching the
SQLModel metadata on an isolated SQLite database.
This project is developed with Cline (a VS Code extension) driving local models served by Ollama. Nothing is sent to a third-party API, and no API key is required. This setup is entirely optional: it does not affect the application, the tests, or the Docker build.
Earlier revisions of this README documented an Aider setup. It was removed in favour of Cline — see Why Cline replaced Aider below.
Install Ollama, install Cline from the VS Code marketplace, then pull the
models. Ollama stores them wherever OLLAMA_MODELS points (a blobs/ directory
holding the weights and a manifests/ directory indexing them); leave the
variable unset to use the default location.
ollama pull gpt-oss:20b # heavy tasks: refactors, features, tests
ollama pull qwen2.5-coder:7b # commit messages, PR titles and descriptions
ollama pull qwen3-coder:30b # alternative for heavy tasksCheck that the local server is answering before starting:
ollama listIn Cline, select the Ollama provider and point it at
http://127.0.0.1:11434; the model list is populated from ollama list.
These are read by the Ollama server, not by the client. On Windows the desktop app reads them at launch, so set them at user scope and restart Ollama:
# 1. Persist them for future sessions (writes to the registry).
[Environment]::SetEnvironmentVariable('OLLAMA_MODELS','D:\llms','User')
[Environment]::SetEnvironmentVariable('OLLAMA_FLASH_ATTENTION','1','User')
[Environment]::SetEnvironmentVariable('OLLAMA_KV_CACHE_TYPE','q8_0','User')
[Environment]::SetEnvironmentVariable('OLLAMA_CONTEXT_LENGTH','65536','User')
# 2. Also set them in THIS shell — see the trap below.
$env:OLLAMA_FLASH_ATTENTION = '1'
$env:OLLAMA_KV_CACHE_TYPE = 'q8_0'
$env:OLLAMA_CONTEXT_LENGTH = '65536'
# 3. Restart the server so it reads them.
Get-Process 'ollama app','ollama' -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Process "$env:LOCALAPPDATA\Programs\Ollama\ollama app.exe"Trap: step 2 is not redundant.
SetEnvironmentVariable(..., 'User')writes to the registry but does not refresh the environment block of processes that are already running — including the shell you are typing in. Restarting Ollama from that shell hands it a stale environment, and it starts with the variables unset while the registry says otherwise. Either set them in the current session as above, or log out and back in before restarting Ollama.
Always verify rather than assume. The server logs its entire configuration at startup, which is the only trustworthy confirmation:
Get-Content "$env:LOCALAPPDATA\Ollama\server.log" |
Select-String 'server config' | Select-Object -Last 1Expect OLLAMA_CONTEXT_LENGTH:65536, OLLAMA_FLASH_ATTENTION:true and
OLLAMA_KV_CACHE_TYPE:q8_0. A CONTEXT_LENGTH:0, FLASH_ATTENTION:false or an
empty KV_CACHE_TYPE means the restart did not pick them up — the models still
load and answer normally, so nothing warns you that the tuning is inactive.
| Variable | Role |
|---|---|
OLLAMA_MODELS |
Where weights are stored. Point it at a data drive — the models are tens of GB |
OLLAMA_FLASH_ATTENTION |
Required for OLLAMA_KV_CACHE_TYPE to take effect; ignored silently without it |
OLLAMA_KV_CACHE_TYPE |
q8_0 halves the memory the context cache needs, for a negligible quality cost |
OLLAMA_CONTEXT_LENGTH |
Server-side default window. A model's own num_ctx overrides it — see below |
OLLAMA_KEEP_ALIVE |
How long an idle model stays in memory (default 5m) |
These variables configure the server only. A client cannot set them.
Cline sends no per-request num_ctx, so the context window has to be baked into
the model itself. Without that, Ollama falls back to its own default and the
window is either too small for the repository map or large enough to fail on
cudaMalloc failed: out of memory. The fix is a derived model:
# gpt-oss-cline.Modelfile
FROM gpt-oss:20b
PARAMETER num_ctx 32768
PARAMETER temperature 1ollama create gpt-oss-cline -f gpt-oss-cline.ModelfileDerived models cost no extra disk: they reference the same blobs as the parent and only add a manifest. The two used here:
| Model | Derived from | Parameters |
|---|---|---|
gpt-oss-cline |
gpt-oss:20b |
num_ctx 32768, temperature 1 |
qwen3-coder-cline |
qwen3-coder:30b |
num_ctx 32768, repeat_penalty 1.1, top_p 0.8, top_k 20, temperature 0.7 |
repeat_penalty 1.1 is the usual guard against a small model looping on the same
paragraph inside a single answer.
| Task | Model | Why |
|---|---|---|
| Refactors, features, writing tests | gpt-oss-cline |
Best quality/speed compromise on 8 GB of VRAM |
| Commit messages, PR titles and descriptions | qwen2.5-coder:7b |
Fits entirely in VRAM, so these stay instant |
Project rules live in AGENTS.md — add it to Cline's context at the start of a
session so ownership checks, CSRF validation and the migration workflow are
respected.
Aider was used first, with a per-model context file and a set of /model
aliases. It was dropped for three practical reasons:
- Fewer hallucinations on the same local models and the same prompts — Cline's tool-based editing gives the model less room to invent file contents than a free-form diff it has to format correctly.
- Editor integration. Cline runs inside VS Code, so the diff to review is the one shown in the editor, instead of a terminal round-trip.
- No client-side model configuration to maintain. Baking
num_ctxinto a Modelfile fixes the window at the source, for every client, rather than in one tool's settings file.
Nothing about the Ollama server setup below changed with the switch — that part is client-agnostic.
The reference machine for this configuration is a Dell G15 5530: RTX 4060 Laptop with 8 GB of VRAM, 32 GB of system RAM. Every value below is derived from that budget — recompute them if your hardware differs.
Active parameters matter more than model size. What a GPU must move for each
generated token is the active parameters, not the total. A mixture-of-experts
model such as qwen3-coder:30b holds 30 B weights but activates only ~3.3 B per
token, so it stays usable even when two thirds of it sits in system RAM. A dense
model activates everything: once qwen2.5-coder:32b overflows 8 GB of VRAM,
every token drags 19 GB across the PCIe bus. This is why a MoE model is the
practical default here while a 32 B dense one is unusable for editing loops.
Measured on the reference machine. Same prompt for every model, a cold load
each time, generation rate as reported by ollama run --verbose:
| Model | Arch. | Size | Load | Prefill (tok/s) | Generation (tok/s) |
|---|---|---|---|---|---|
qwen2.5-coder:7b |
dense | 4.7 GB | 3.5 s | 276.0 | 31.9 |
deepseek-coder-v2:16b |
MoE | 8.9 GB | 21.8 s | 23.9 | 18.8 |
qwen3-coder:30b |
MoE | 18 GB | 35.3 s | 21.7 | 17.5 |
gpt-oss:20b |
MoE | 13 GB | 27.9 s | 61.8 | 15.9 |
qwen2.5-coder:14b |
dense | 9.0 GB | 30.4 s | 54.2 | 6.8 |
qwen2.5-coder:32b |
dense | 19 GB | 38.4 s | 21.6 | 1.85 |
Read it by architecture, not by size. deepseek-coder-v2:16b (MoE, 8.9 GB) is
2.8× faster than qwen2.5-coder:14b (dense, 9.0 GB) at essentially the same
footprint — the only difference is how many parameters each token activates.
Below the VRAM ceiling that penalty disappears: the 7 B model is dense and the
fastest of the set precisely because it never spills.
At the other end, qwen2.5-coder:32b produces 1.85 tok/s — roughly seven
minutes for a single answer. It is not a slow option, it is not an option.
Two cautions when comparing these figures. Prefill (ingesting the prompt) and generation are different rates, and the first is several times the second — quoting the prefill number overstates what the model actually feels like. And these come from one prompt on one machine: expect drift with a different context length or a warm cache, but the ordering holds.
The context window is not free. Ollama 0.32 defaults to 4096 tokens, which
silently truncates the repository map and the files submitted for editing. But
raising it allocates a KV cache that grows linearly with the window and sits
in memory on top of the weights. For qwen3-coder:30b (48 layers, 4 KV
heads, head_dim 128) one token costs ~96 KB in f16:
| Window | KV cache f16 | KV cache q8_0 | Total with 17.3 GB of weights |
|---|---|---|---|
| 32K | 3.1 GB | 1.6 GB | 18.9 GB |
| 64K | 6.3 GB | 3.1 GB | 20.4 GB |
| 128K | 12.6 GB | 6.3 GB | 23.6 GB |
| 256K | 25.2 GB | 12.6 GB | 29.9 GB — will not load |
Advertising a 256K context does not mean your hardware can hold it.
That table only counts total memory, though, and fitting is not the goal — staying on the GPU is. Both windows were measured on the reference machine:
num_ctx |
Footprint | CPU/GPU split | Free system RAM |
|---|---|---|---|
| 65536 | 22 GB | 72% / 28% | 3.3 GB |
| 32768 | 20 GB | 69% / 31% | 6.2 GB |
Generation runs at ~16-17 tok/s either way (the table above measured 17.5 at 32768). The lesson is that shrinking the window buys far less GPU residency than the arithmetic suggests: 18 GB of weights will never fit in 6.9 GB of usable VRAM whatever the context, so the split is dominated by model size, not by the KV cache. Going below 32K would gain almost nothing.
The real payoff is elsewhere — free system RAM nearly doubles, which is what keeps the machine off the page file when a browser or an editor asks for memory mid-generation. That is the reason to prefer 32768 here, not the GPU split.
If you genuinely want a mostly-GPU-resident model, the only lever is a smaller
one: deepseek-coder-v2:16b (8.9 GB) fits far better, at a cost in quality.
OLLAMA_CONTEXT_LENGTH stays at 65536 as a server-side ceiling, but the
per-model value is what actually runs.
First load takes time. Reading 18 GB from disk costs ~100 s; afterwards the
model stays resident for OLLAMA_KEEP_ALIVE (5 minutes). Raise that variable to
30m if reloads become annoying — at the cost of holding the memory that long.
Precedence trap. The effective window is the first of: an option sent with
the request, then the model's own PARAMETER num_ctx, then
OLLAMA_CONTEXT_LENGTH. Cline sends nothing, so the Modelfile value is what
actually runs — and a model used without a derived variant silently falls back
to the server default. Create the variant for every model you intend to drive
from Cline.
Measure, do not trust the table. The figures above are arithmetic, not a benchmark of your machine. Start a session, then in a second terminal:
ollama ps # qwen3-coder:30b 20 GB 69%/31% CPU/GPU 32768Read the split together with free system RAM, not on its own: a high CPU share is expected here and still yields usable speed, whereas an exhausted RAM budget means swapping and does not. Confirm the CUDA device is the one being used — Ollama excludes integrated GPUs by default, and the startup log says so explicitly:
Get-Content "$env:LOCALAPPDATA\Ollama\server.log" |
Select-String 'inference compute|dropping integrated GPU' | Select-Object -Last 2On the reference machine that reports library=CUDA … RTX 4060 Laptop GPU and
dropping integrated GPU for the Intel controller — the discrete card is doing
the work.
Two different things get called "cache". Only one of them ever touches the disk.
The KV cache never does. It lives in VRAM and RAM, is rebuilt for each
loaded model, and is released when Ollama unloads it after OLLAMA_KEEP_ALIVE
(5 minutes idle by default). There is nothing to delete and no maintenance to
schedule — restarting Ollama, or simply waiting, frees it. To release it now:
ollama ps # models currently resident in memory
ollama stop qwen3-coder:30bThe weights do, and they are the only large item. They live under
OLLAMA_MODELS — D:\llms here, not on C: — and grow only when you run
ollama pull. They never grow on their own, so no periodic cleanup is needed;
you delete a model when you have stopped using it:
Get-ChildItem D:\llms\blobs -File | Measure-Object Length -Sum # actual footprint
ollama rm qwen2.5-coder:32b # frees its blobsAlways use ollama rm. Deleting files inside blobs/ by hand leaves the
manifests pointing at missing layers, and the model then fails to load with no
way to repair it short of re-pulling. Note that blobs are shared: a layer used
by two models is only reclaimed once the last of them is removed, so ollama rm
does not always free the size shown by ollama list.
What remains on C: is negligible and needs no upkeep:
| Path | Contents | Growth |
|---|---|---|
%LOCALAPPDATA%\Programs\Ollama |
The Ollama binaries | Fixed; the installer manages it |
%LOCALAPPDATA%\Ollama |
server.log, app.log, db.sqlite |
Under a MB, rotated automatically |
%USERPROFILE%\.ollama |
SSH keypair identifying the machine to the registry, plus an empty cache/ |
Effectively zero — do not delete the keys |
Cline stores its task history in the VS Code extension's own storage, not in
the repository, so nothing accumulates here and .gitignore needs no entry for
it.
Review AI-written diffs like any other contribution: the project rules in
AGENTS.md — ownership checks, CSRF validation, Alembic migrations — still
apply.
This section explains the key dependencies and why they were chosen.
| Package | Role |
|---|---|
fastapi |
Web framework — chosen for its automatic OpenAPI docs, native Pydantic integration, and async support |
uvicorn |
ASGI server used to run the FastAPI app in production |
starlette |
Underlying toolkit FastAPI is built on (routing, middleware, static files) |
jinja2 |
HTML templating engine for server-side rendering |
python-multipart |
Required to handle HTML form submissions (login, registration) |
| Package | Role |
|---|---|
sqlmodel |
ORM chosen for its dual role: defines models used both as database tables and as Pydantic validation schemas, avoiding code duplication |
sqlalchemy |
Underlying database engine used by SQLModel |
alembic |
Applies versioned and reproducible database schema migrations |
psycopg |
PostgreSQL driver used by SQLAlchemy in Docker |
greenlet |
Required by SQLAlchemy for async context support |
| Package | Role |
|---|---|
argon2-cffi |
Argon2 password hashing — chosen over bcrypt or SHA-256 for its resistance to GPU and ASIC brute-force attacks (memory-hard algorithm) |
pwdlib |
High-level wrapper around argon2-cffi for password hashing/verification |
pyjwt |
JWT token generation and verification for stateless authentication |
python-dotenv |
Loads environment variables (secret key, config) from a .env file |
| Package | Role |
|---|---|
pydantic |
Data validation library, used natively by FastAPI for request/response schemas |
annotated-types |
Extends Python type annotations, used internally by Pydantic |
| Package | Role |
|---|---|
fastapi-cli |
Provides the fastapi run and fastapi dev commands |
click, typer, rich |
CLI utilities used internally by fastapi-cli |
watchfiles |
File watching for hot-reload in development mode |
| Package | Role |
|---|---|
pytest |
Discovers and executes unit and HTTP integration tests |
httpx |
Provides the transport used by Starlette's synchronous TestClient |
pytest-cov, coverage |
Measure line and branch coverage and enforce the initial CI threshold |
Note: Some packages in
requirements.txtare transitive dependencies (automatically installed by the packages above) and are not imported directly in the application code.
Docker Compose starts three containers:
eportfolio-app: the FastAPI application, available on port8000.eportfolio-db: PostgreSQL, available on port5432and persisted in thepostgres_dataDocker volume.eportfolio-pgadmin: pgAdmin, available on port5050and persisted in thepgadmin_dataDocker volume.
Copy .env.example to .env, then replace SECRET_KEY, POSTGRES_PASSWORD, and PGADMIN_DEFAULT_PASSWORD. SECRET_KEY must contain at least 32 characters and cannot keep the example placeholder. Docker Compose deliberately refuses to start when required variables are absent. The real .env file is ignored by Git and must remain local to each developer or deployment server.
Linux/macOS:
cp .env.example .env
chmod 600 .envWindows PowerShell:
Copy-Item .env.example .envFor production, use the following security settings:
APP_ENV=production
COOKIE_SECURE=true
SEED_DEMO_DATA=falseCOOKIE_SECURE defaults to true and demonstration seeding defaults to false when APP_ENV=production. Development keeps HTTP cookies and sample data available unless these values are explicitly overridden.
docker build -t e-portfolio .docker compose up -ddocker compose up -d --builddocker compose ps
docker compose logs -fUse localhost:5432 from a database client such as DBeaver or pgAdmin. The database name, user, password, and exposed port are configured in .env. PostgreSQL is bound to the host's loopback interface, so its port is not publicly exposed by the server.
To open a SQL shell inside the database container:
docker compose exec db psql -U eportfolio -d eportfoliodocker compose down keeps the database. Use docker compose down -v only when you intentionally want to delete the PostgreSQL data volume.
Open http://127.0.0.1:5050 and sign in with PGADMIN_DEFAULT_EMAIL and PGADMIN_DEFAULT_PASSWORD from .env.
On the first connection, register the PostgreSQL server with these values:
| Field | Value |
|---|---|
| Name | ePortfolio |
| Host name/address | db |
| Port | 5432 |
| Maintenance database | value of POSTGRES_DB |
| Username | value of POSTGRES_USER |
| Password | value of POSTGRES_PASSWORD |
Use db, not localhost, because pgAdmin connects to PostgreSQL through the internal Docker Compose network.
The files in schemas/ are SQLModel table definitions. Changing one of these
files changes the expected Python metadata, but it does not alter an existing
PostgreSQL database by itself. Alembic records every structural database change
as a versioned Python file under migrations/versions/.
At application startup, main.py automatically runs the equivalent of
alembic upgrade head. This applies existing migrations, but generating a new
migration remains an explicit developer action so that its SQL operations can
be reviewed before they reach a shared database.
Run every command below from the repository root in PowerShell.
- Start PostgreSQL and make sure the application image contains the current dependencies:
docker compose up -d db
docker compose build eportfolio- Apply all migrations that already exist before changing the schema:
docker compose run --rm --no-deps --volume "${PWD}:/app" eportfolio python -m alembic upgrade head- Modify the relevant file, for example
schemas/User.py:
bio: str | None = Field(default=None)- Generate a migration. The bind mount is required so that the generated file is written into the host repository instead of disappearing with the temporary container:
docker compose run --rm --no-deps --volume "${PWD}:/app" eportfolio python -m alembic revision --autogenerate -m "add user bio"-
Open the new file in
migrations/versions/and review bothupgrade()anddowngrade(). Autogeneration is only a proposal. In particular:- a renamed column can be detected as a destructive drop followed by an add;
- a new non-nullable column can fail when the table already contains rows;
- a type change can require an explicit PostgreSQL conversion;
- an unexpected
drop_table()ordrop_column()must not be applied.
-
Apply and verify the new migration:
docker compose run --rm --no-deps --volume "${PWD}:/app" eportfolio python -m alembic upgrade head
docker compose run --rm --no-deps --volume "${PWD}:/app" eportfolio python -m alembic current
docker compose run --rm --no-deps --volume "${PWD}:/app" eportfolio python -m alembic checkalembic check must report No new upgrade operations detected.
- Rebuild and test the application through Docker:
docker compose up -d --build eportfolio
docker compose logs -f eportfolio- Commit the schema and its migration together:
git add schemas/User.py migrations/versions/
git commit -m "feat(database): add user bio"A migration is required for tables, columns, SQL types, nullability, database defaults, unique constraints, indexes, foreign keys, and check constraints. It is not required for route logic, templates, CSS, or application-only validation rules.
Never edit an already merged and applied migration to represent a later schema change. Create a new migration instead. Never test a destructive downgrade on the shared development or production database.
The production environment uses an automated deployment strategy directly executed on the virtual machine.
Every 2 minutes, a deployment script checks the prod-vm branch and automatically deploys any new version.
#!/bin/bash
echo "DEPLOY $(date)"
cd /home/yassine/web_prog/e-portfolio || exit 1
git pull origin prod-vm
sudo docker compose up -d --build
sudo docker image prune -f
echo "DEPLOY DONE $(date)"*/2 * * * * /home/yassine/deploy.sh >> /home/yassine/deploy.log 2>&1- Fetch latest code from GitHub.
- Pull updates from the
prod-vmbranch. - Rebuild Docker image.
- Recreate application container.
- Remove unused Docker images.
- Store deployment logs.
/home/yassine/deploy.log
- Fully automated deployment.
- No manual intervention required.
- No public SSH exposure.
- Compatible with Proxmox and NAT environments.
- Automatic Docker image cleanup.
- Continuous synchronization with the production branch.
- PostgreSQL and SQLite schemas are upgraded through Alembic when the application starts.
- Demonstration data is enabled by default only outside production and is controlled by
SEED_DEMO_DATA. - Authentication relies on JWT tokens stored in HTTP-only cookies and all state-changing forms use CSRF tokens.
- Passwords are hashed before storage using Argon2.
- Pagination is implemented on the public homepage and search results.
- Docker is used for production deployment.