Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ dist/
.env
.env.local
backend/.env
backend/.env.*
!backend/.env.example

# Editor
.DS_Store
.vscode/
*.swp

# Python
backend/.venv/
venv/
.venv/
__pycache__/
*.pyc
*.pyo
Expand All @@ -26,3 +31,4 @@ backend/uploads/

# Misc
*.log
.neon
147 changes: 92 additions & 55 deletions DEPLOY.md
Original file line number Diff line number Diff line change
@@ -1,69 +1,106 @@
# Deployment Guide

## Step 1 — Create GitHub Repository
| Part | Host | Notes |
|---|---|---|
| Frontend (Vite + React) | Vercel | `www.moderdiry.com` |
| Backend API (FastAPI) | Render, free web service | `render.yaml`, root directory `backend` |
| Database | Neon Postgres | project `summer-voice-22976145`, branch `production`, region `aws-eu-west-2` |

1. Go to https://github.com/new
2. Create a repo named `portfolio` (private or public)
3. Run these commands in the project folder:
Uploaded files (profile photo, resume, screenshots) are stored in Postgres (`uploaded_files` table),
because Render's free disk is wiped on every restart. They are still served at `/api/uploads/{filename}`.

`railway.toml` and `backend/railway.toml` are unused leftovers from the old Railway setup.

## 1. Database: Neon

The project is already linked (`.neon`, `neon.ts`). To pull its connection strings into `.env.local` (git-ignored):

```bash
neon login # once, opens a browser
neon env pull # writes DATABASE_URL (pooled) and DATABASE_URL_UNPOOLED (direct)
```

The backend uses `DATABASE_URL_UNPOOLED` when it is set (one long-running process with its own
connection pool that also creates tables at startup); otherwise `DATABASE_URL`. Neon's `sslmode`
is converted to asyncpg's `ssl` option automatically (`backend/app/core/config.py`).

Tables are created at startup. The seed (`backend/app/services/seed.py`) fills empty tables and creates
the admin user only if none exists **and** `ADMIN_PASSWORD` is set.

## 2. Backend: Render

Either create a Blueprint from `render.yaml` (Render → New → Blueprint → this repo), or create the
service by hand:

| Field | Value |
|---|---|
| Service type | Web Service |
| Repository | `Derdiry/portfolio` |
| Branch | `main` |
| Root directory | `backend` |
| Runtime | Python 3 |
| Region | Frankfurt (closest to the Neon project in London) |
| Build command | `pip install -r requirements.txt` |
| Start command | `uvicorn main:app --host 0.0.0.0 --port $PORT` |
| Instance type | Free |
| Health check path | `/health` |

Environment variables:

| Name | Value |
|---|---|
| `PYTHON_VERSION` | `3.10.12` |
| `DATABASE_URL_UNPOOLED` | Neon direct connection string (secret) |
| `JWT_SECRET` | long random string (secret), see below |
| `CORS_ORIGINS` | `https://www.moderdiry.com,https://moderdiry.com` |

Generate `JWT_SECRET` locally and paste it straight into Render (don't save it in a file):

```bash
git init
git add .
git commit -m "Initial portfolio commit"
git branch -M main
git remote add origin https://github.com/Derdiry/portfolio.git
git push -u origin main
python3 -c "import secrets; print(secrets.token_urlsafe(64))"
```

## Step 2 — Deploy Frontend to Vercel

1. Go to https://vercel.com and sign up with GitHub
2. Click "Add New Project" → import your `portfolio` repo
3. **Framework Preset:** Vite
4. **Root Directory:** `.` (the project root)
5. **Build Command:** `npm run build`
6. **Output Directory:** `dist`
7. **Environment Variables — add:**
- `VITE_API_URL` = `https://your-backend.railway.app` (fill in after Step 3)
8. Click Deploy

Your site will be live at `mohamedalderdiry.vercel.app` (or similar).

## Step 3 — Deploy Backend to Railway

1. Go to https://railway.app and sign up with GitHub
2. Click "New Project" → "Deploy from GitHub repo" → select `portfolio`
3. **Root Directory:** `backend`
4. Railway will detect the Dockerfile automatically
5. Add a **PostgreSQL** service: click "New" → "Database" → "PostgreSQL"
6. In the backend service, add these **Environment Variables:**
- `DATABASE_URL` = (click the PostgreSQL service → "Connect" → copy the URL, change `postgresql://` to `postgresql+asyncpg://`)
- `DATABASE_URL_SYNC` = (same URL but keep `postgresql://`)
- `JWT_SECRET` = (generate a random 32-char string)
- `ADMIN_USERNAME` = `admin`
- `ADMIN_PASSWORD` = (your secure password)
- `CORS_ORIGINS` = `https://mohamedalderdiry.vercel.app`
- `CONTACT_RECIPIENT` = `mod.derdiry@gmail.com`
7. Deploy — Railway will build the Docker image and run the server

## Step 4 — Connect Frontend to Backend

1. In Vercel dashboard → your project → Settings → Environment Variables
2. Update `VITE_API_URL` to your Railway backend URL (e.g. `https://portfolio-backend.railway.app`)
3. Redeploy: Vercel dashboard → Deployments → "Redeploy"

## Step 5 — Custom Domain (optional, do later)

1. In Vercel dashboard → your project → Settings → Domains
2. Click "Buy Domain" to purchase `mohamedalderdiry.com` (~$10/yr)
3. Vercel handles DNS automatically
`ADMIN_USERNAME` / `ADMIN_PASSWORD` are not needed while the admin user exists in the database.
SMTP variables are not set: Render's free tier blocks outbound SMTP ports (25, 465, 587), so contact
form messages are saved in the `contact_messages` table without an email notification.

Free-tier behaviour: the service sleeps after 15 minutes without traffic and takes about a minute to
wake. Free instance hours (750 per month) are shared by all free services in the Render workspace.

## 3. Frontend: Vercel

- Framework preset: Vite. Build command: `npm run build`. Output directory: `dist`.
- Environment variable `VITE_API_URL` = the Render service URL (e.g. `https://portfolio-api.onrender.com`).
- `vercel.json` proxies `/uploads/:file` to `<backend>/api/uploads/:file`; keep it pointed at the same backend.
- Redeploy after changing `VITE_API_URL` (it is read at build time).

## 4. Admin password

Passwords are stored as bcrypt hashes. To reset the admin password, run from your machine with the
Neon connection string in `.env.local` (the script prompts for the new password and never prints it):

```bash
cd backend
pip install -r requirements.txt
python -m app.services.reset_admin_password
```

## Local development

`.env.local` points the backend at whichever Neon branch is checked out, so work on a dev branch,
not `production` (new branches expire after 7 days, per `neon.ts`):

```bash
# Start backend + database
docker compose up -d
neon checkout dev --create # copies production; pulls its env into .env.local
cd backend && pip install -r requirements.txt
uvicorn main:app --reload # http://localhost:8000
npm run dev # in the project root, http://localhost:5173
neon checkout production # switch back when done
```

# Start frontend
Fully local alternative (Postgres in Docker; uses `backend/.env`, see `backend/.env.example`):

```bash
docker compose up -d
npm run dev
```
21 changes: 21 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Secrets and local env files
.env
.env.*
!.env.example

# Local virtualenvs
.venv
venv

# Python caches and test artifacts
**/__pycache__
**/*.pyc
**/*.pyo
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
.coverage
htmlcov

# Local uploads (files are stored in Postgres)
uploads/
57 changes: 34 additions & 23 deletions backend/app/api/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import io
import os
import uuid
from collections import defaultdict
from datetime import datetime, timedelta, timezone
Expand All @@ -12,7 +11,7 @@

from app.core.database import get_db
from app.core.security import get_current_user
from app.models.models import Analytics, Education, Experience, Profile, Project, Skill
from app.models.models import Analytics, Education, Experience, Profile, Project, Skill, UploadedFile
from app.schemas.schemas import (
AnalyticsStats,
EducationCreate,
Expand Down Expand Up @@ -70,6 +69,27 @@ async def update_profile(
)


# ── Stored files ─────────────────────────────────────────────────────────────

def _jpeg_bytes(image: Image.Image, quality: int) -> bytes:
buf = io.BytesIO()
image.save(buf, "JPEG", quality=quality)
return buf.getvalue()


async def _store_file(db: AsyncSession, filename: str, content_type: str, data: bytes) -> str:
"""Insert or replace a file in Postgres (caller commits); returns its public URL."""
await db.merge(UploadedFile(filename=filename, content_type=content_type, data=data))
return f"/uploads/{filename}"


async def _delete_file(db: AsyncSession, url: str | None) -> None:
if url and url.startswith("/uploads/"):
stored = await db.get(UploadedFile, url.removeprefix("/uploads/"))
if stored:
await db.delete(stored)


# ── Photo Upload ─────────────────────────────────────────────────────────────

@router.post("/profile/photo", response_model=PhotoUploadResponse)
Expand All @@ -90,19 +110,15 @@ async def upload_profile_photo(
# Resize to 400x400
image = image.resize((400, 400), Image.LANCZOS)

# Save to uploads/
filename = f"{uuid.uuid4().hex}.jpg"
os.makedirs("uploads", exist_ok=True)
image.save(os.path.join("uploads", filename), "JPEG", quality=90)
photo_url = await _store_file(db, f"{uuid.uuid4().hex}.jpg", "image/jpeg", _jpeg_bytes(image, 90))

photo_url = f"/uploads/{filename}"

# Update profile
# Update profile, dropping the photo it replaces
result = await db.execute(select(Profile).where(Profile.id == 1))
profile = result.scalar_one_or_none()
if profile:
await _delete_file(db, profile.photo_url)
profile.photo_url = photo_url
await db.commit()
await db.commit()

return PhotoUploadResponse(photo_url=photo_url)

Expand All @@ -116,11 +132,9 @@ async def upload_resume(
):
if not file.content_type or file.content_type != "application/pdf":
raise HTTPException(status_code=400, detail="File must be a PDF")
content = await file.read()
os.makedirs("uploads", exist_ok=True)
with open("uploads/resume.pdf", "wb") as f:
f.write(content)
return PhotoUploadResponse(photo_url="/uploads/resume.pdf")
resume_url = await _store_file(db, "resume.pdf", "application/pdf", await file.read())
await db.commit()
return PhotoUploadResponse(photo_url=resume_url)


# ── Projects ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -184,12 +198,12 @@ async def upload_project_screenshot(
image = Image.open(io.BytesIO(content)).convert("RGB")
image.thumbnail((1280, 960), Image.LANCZOS)

filename = f"screenshot_{project_id}_{uuid.uuid4().hex[:8]}.jpg"
os.makedirs("uploads", exist_ok=True)
image.save(os.path.join("uploads", filename), "JPEG", quality=85)
url = await _store_file(
db, f"screenshot_{project_id}_{uuid.uuid4().hex[:8]}.jpg", "image/jpeg", _jpeg_bytes(image, 85)
)

screenshots = list(project.screenshots or [])
screenshots.append(f"/uploads/{filename}")
screenshots.append(url)
project.screenshots = screenshots
flag_modified(project, "screenshots")
await db.commit()
Expand All @@ -212,12 +226,9 @@ async def delete_project_screenshot(
screenshots = [s for s in (project.screenshots or []) if s != url]
project.screenshots = screenshots
flag_modified(project, "screenshots")
await _delete_file(db, url)
await db.commit()

filepath = os.path.join("uploads", filename)
if os.path.exists(filepath):
os.remove(filepath)

await db.refresh(project)
return project

Expand Down
15 changes: 8 additions & 7 deletions backend/app/api/public.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import os
from collections import defaultdict
from datetime import datetime, timedelta, timezone

from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import FileResponse
from fastapi.responses import Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.models.models import Analytics, ContactMessage, Education, Experience, Profile, Project, Skill
from app.models.models import Analytics, ContactMessage, Education, Experience, Profile, Project, Skill, UploadedFile
from app.schemas.schemas import (
AnalyticsStats,
ContactRequest,
Expand Down Expand Up @@ -122,11 +121,13 @@ async def contact(body: ContactRequest, db: AsyncSession = Depends(get_db)):
# ── Uploads (served via API so CORSMiddleware adds headers) ──────────────────

@router.get("/uploads/{filename}")
async def serve_upload(filename: str):
path = os.path.join("uploads", filename)
if not os.path.exists(path):
async def serve_upload(filename: str, db: AsyncSession = Depends(get_db)):
stored = await db.get(UploadedFile, filename)
if not stored:
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path)
# Photos and screenshots get unique names; resume.pdf is replaced in place.
cache = "no-cache" if filename == "resume.pdf" else "public, max-age=86400"
return Response(content=stored.data, media_type=stored.content_type, headers={"Cache-Control": cache})


# ── Analytics ────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading