A minimal personal photography portfolio built with Next.js (App Router), Postgres, and DigitalOcean Spaces.
- Next.js 15 (App Router, Turbopack)
- TypeScript
- Tailwind CSS + shadcn/ui
- Postgres + Prisma 7
- DigitalOcean Spaces (S3-compatible object storage)
- NextAuth v5 (single-user Credentials auth)
- Zod for runtime validation
- Node.js 20.19+ / 22.12+ / 24+
- pnpm
- PostgreSQL (local or hosted)
- DigitalOcean Spaces bucket (or any S3-compatible storage)
# Install dependencies
pnpm install
# Copy environment file and fill in values
cp .env.example .env
# Generate Prisma client
pnpm db:generate
# Run database migrations
pnpm db:migrate
# Start development server
pnpm devSee .env.example for the full list. Key variables:
DATABASE_URL— Postgres connection stringNEXTAUTH_URL— App base URL (e.g.http://localhost:3000)NEXTAUTH_SECRET— Random secret for JWT signingADMIN_USERNAME/ADMIN_PASSWORD_HASH— Single admin credentials (bcrypt hash)STORAGE_*— DigitalOcean Spaces configurationSTORAGE_PUBLIC_BASE_URL— CDN/public URL for serving images
node -e "const bcrypt = require('bcryptjs'); bcrypt.hash('your-password', 12).then(h => console.log(h))"| Script | Description |
|---|---|
pnpm dev |
Start dev server with Turbopack |
pnpm build |
Production build |
pnpm lint |
Run ESLint |
pnpm typecheck |
Run TypeScript type checking |
pnpm db:generate |
Generate Prisma client |
pnpm db:migrate |
Run Prisma migrations |
pnpm db:studio |
Open Prisma Studio |
pnpm db:push |
Push schema to DB (no migration) |
Your Spaces bucket needs CORS configured to allow PUT uploads from the admin origin:
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>https://your-domain.com</AllowedOrigin>
<AllowedMethod>PUT</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<MaxAgeSeconds>3600</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>- Files → DigitalOcean Spaces (original/preview/thumb variants)
- Metadata → Postgres (title, description, EXIF, tags, publish state)
- Upload flow: Admin selects files → EXIF extracted in browser → preview/thumb generated via canvas → signed PUT URLs → direct upload to Spaces → metadata committed to DB
- Public gallery: reads only published, non-deleted photos from DB
- Soft-delete: photos are never hard-deleted,
deletedAttimestamp is used
- App: Vercel
- Database: Supabase / Neon
- Storage: DigitalOcean Spaces
- All env vars set in Vercel
- Admin auth working
- Storage CORS configured for production domain
- Preview/thumb publicly accessible
- Originals not publicly exposed