MEHDI.
RETURN_TO_INDEX

Building My Self-Hosted Developer Portfolio with Next.js, Prisma and Docker

7 min read
#TypeScript#Docker#Prisma#Self-Hosting#Next.js#SQLite

Most developer portfolios follow the same template: a hero section, a grid of projects pulled from a JSON file or hardcoded directly into the page, and a contact form that may or may not actually work. When I started planning my own portfolio, I realized that approach would not give me what I wanted: a site I could update from a browser, deploy on my own infrastructure, and extend without fighting a theme system I did not control.

I decided to build the entire thing from scratch. Not because templates are bad, but because I wanted the portfolio itself to be a technical artifact I could talk about in interviews and document honestly.

What I Actually Needed

Before writing any code, I listed the requirements:

  • A public-facing site with projects, blog posts, and static pages
  • An administration panel where I could create and edit content without touching code
  • A database-backed architecture, not a flat-file CMS
  • A deployment workflow that did not depend on Vercel, Netlify or any external platform
  • A system that would not break when I added new features

The non-requirement was equally important: I did not need real-time collaboration, multi-user support, or a plugin ecosystem. This is a personal portfolio, not a SaaS product.

The Stack

I went with Next.js 15 using the App Router, written entirely in TypeScript. For styling, I chose Tailwind CSS with custom design tokens rather than a component library. The database layer is Prisma with SQLite, and the whole thing runs in a Docker container on my own server.

The monorepo structure keeps things organized:

apps/frontend/       # Next.js application
packages/database/   # Prisma schema and client

This separation means the database package can be imported by any future application without pulling in Next.js dependencies.

Data Modeling with Prisma

The Prisma schema defines four main models: Project, BlogPost, Tag, and Message. Projects and blog posts both have a many-to-many relationship with tags, which means I can reuse tags across content types without duplication.

The slug field on both Project and BlogPost has a @unique constraint. This is critical: the slug is the stable identifier used in URLs, in the seed system, and in the admin panel's upsert logic. If I change a project title, the slug stays the same and old links continue to work.

model Project {
  id          String   @id @default(cuid())
  title       String
  slug        String   @unique
  description String
  excerpt     String?
  tags        Tag[]    @relation("ProjectTags")
  githubUrl   String?
  liveUrl     String?
  image       String?
  date        String?
  order       Int      @default(0)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

The date field is a String, not a DateTime. This was a deliberate choice: many of my projects span academic years or seasons (like "Summer 2025" or "2025-2026"), and forcing those into a strict date format would lose meaningful information.

Why SQLite

SQLite gets dismissed as "not a real database" by people who have never used it in production. For a personal portfolio with a handful of projects and blog posts, it is more than adequate. The entire database is a single file, which makes backups trivial: copy the file.

The real advantage is operational simplicity. There is no database server to configure, no connection pooling to tune, no separate container to manage. The SQLite file lives in a Docker volume at /app/data/dev.db, and Prisma handles all the queries.

The tradeoff is concurrency. SQLite uses file-level locking, so concurrent writes are serialized. For a portfolio where the only writes happen when I edit content through the admin panel, this is not a problem. If I ever needed to support hundreds of concurrent write operations, I would switch to PostgreSQL. But solving problems I do not have yet is not how I want to spend my time.

The Administration Panel

The admin panel is at /admin and requires authentication. I can create, edit and delete projects and blog posts directly from the browser. The forms use Zod schemas for validation on both client and server, which means the same validation rules apply regardless of where the request comes from.

When I create or update a project through the admin panel, the tags are handled with Prisma's connectOrCreate: if a tag already exists, it gets connected; if not, it gets created. This prevents duplicate tags without requiring a separate "manage tags" page.

Docker Deployment

The Dockerfile uses a multi-stage build. The first stage installs dependencies, the second generates the Prisma client, the third builds the Next.js standalone output, and the final stage copies only what is needed to run.

The Docker entrypoint handles the startup sequence:

  1. Normalize the DATABASE_URL path
  2. Fix file permissions on the data volume
  3. Run prisma db push to apply any schema changes
  4. Run the portfolio seed (idempotent, never overwrites)
  5. Run the blog seed (idempotent, never overwrites)
  6. Start the Next.js server

This order matters. Migrations must happen before seeding, and seeding must happen before the application starts accepting requests. If the database file does not exist yet, prisma db push creates it.

The docker-compose.yml mounts two volumes: one for the SQLite database at /app/data, and one for uploaded images at /app/apps/frontend/public/uploads. Both survive container rebuilds.

What I Learned

Building this portfolio taught me more about deployment than any tutorial. The gap between "it works on my machine" and "it runs reliably in a container" is larger than most people admit.

A few things that surprised me:

SQLite file permissions are a real problem. When the container runs as a non-root user (which it should), the SQLite file and its parent directory must be owned by that user. My entrypoint script handles this with chown, but it took several failed deployments before I understood why the database was empty after every restart.

The Docker entrypoint is as important as the application. Most tutorials focus on the Dockerfile and ignore the entrypoint. But the entrypoint is where you handle migrations, seeding, permission fixes, and the actual startup sequence. A bad entrypoint can silently lose data.

Prisma's db push is not the same as migrate. For development and small projects, db push applies schema changes directly. For production with multiple developers or strict migration requirements, prisma migrate deploy is the right choice. I use db push because I am the only developer and the schema is still evolving.

Self-hosting is a commitment. When something breaks at 2 AM, there is no support ticket. The backups, the monitoring, the security updates -- that is all on me. But the flip side is that I understand every layer of the system, and I can fix problems without waiting for a platform's status page to acknowledge them.

This portfolio is not just a collection of projects. It is a project in itself, and one I continue to learn from.