Jacob Smythe · Portfolio
← Back to projects
Professional Portfolio Hub cover image

published

Professional Portfolio Hub

Built from scratch with Astro and deployed on a hardened DigitalOcean VPS with Nginx, TLS, and GitHub Actions CI/CD.

Published Apr 30, 2026

  • astro
  • tailwind
  • portfolio
  • digitalocean
  • nginx
  • ci-cd

Objective

I built this portfolio from scratch to publish technical work in a structured way and run it on infrastructure I control.

The target was a static-first site with clear routing, reliable builds, and a clean production path on a VPS.

Scope and Constraints

I kept the scope tight from day one:

  • no backend runtime for public pages
  • low-cost hosting with predictable behavior
  • typed, validated content for projects, labs, and perimeter posts
  • repeatable release checks before publish

Deploy and operations were part of the project scope, not a later add-on.

Tech Stack

  • Astro
  • Tailwind CSS
  • Astro Content Collections with schema validation
  • TypeScript for page-level logic where needed
  • DigitalOcean Droplet (Ubuntu + Nginx)
  • Certbot / Let’s Encrypt
  • GitHub Actions deployment over SSH

Architecture

The platform is static-first with a strict separation between authoring, build-time validation, and runtime delivery.

flowchart TD
    A["Content Authoring<br/>src/content/projects|labs|perimeter/*.md"] --> B["Astro Content Collections<br/>Schema Validation"]
    B --> C["Page Composition Layer<br/>Layouts + Components + Route Templates"]
    C --> D["Astro Build<br/>npm run build"]
    D --> E["Static Output<br/>dist/"]

    F["GitHub Repository<br/>main branch"] --> G["GitHub Actions<br/>deploy-droplet.yml"]
    G --> H["Droplet SSH Session<br/>deploy user"]
    H --> I["Build on Droplet<br/>git pull + npm ci + npm run build"]
    I --> J["Sync Step<br/>rsync --delete --chown to /var/www/portfolio"]

    E -.local validation artifact.- I
    J --> K["Nginx Static Serving<br/>80/443"]
    L["DNS A Records<br/>apex + www"] --> K
    M["Certbot / Let's Encrypt<br/>TLS + HTTPS redirect"] --> K
    K --> N["Public Site<br/>jacobthree.dev"]

    O["Operational Guardrails<br/>UFW + sshd checks + nginx -t"] --> K
    O --> I

Layer Breakdown

  • Authoring layer: Markdown entries live in collections and must satisfy schema requirements.
  • Build layer: Astro composes routes and components into static files in dist/.
  • Delivery layer: Nginx serves only static assets from /var/www/portfolio.
  • Automation layer: GitHub Actions executes pull/build/sync on droplet over SSH.
  • Security and ops layer: UFW rules, SSH validation, nginx config tests, and TLS renewal support uptime and safe changes.

Data and Request Flow

  • Content changes start in markdown and git commits.
  • Deployment workflow rebuilds and publishes static artifacts to Nginx web root.
  • Browser requests resolve through DNS, terminate TLS at Nginx, then return prebuilt files.
  • No application runtime handles requests at serve time, which keeps production behavior simple and predictable.

How I Built It

I shipped this in phases and only moved forward after each phase worked.

Foundation

I set up the core route structure first: /, /projects, /labs, /perimeter, /about, and /resume.

Then I added dynamic slug routes so new entries ship by adding markdown, not new templates.

Content Model

I defined collection schemas to enforce required fields and status values. Build-time validation catches bad metadata before release.

That made content predictable and reduced manual QA.

Shared UI

I built reusable page pieces for navigation, social links, cards, and detail layouts. This cut duplicate markup and kept the site visually consistent.

Publishing Behavior

I standardized sorting across project and blog-style lists:

  • first key: newest publishedAt
  • tie-break: title alphabetical

That keeps list order deterministic across pages.

DigitalOcean VPS Deployment

I deployed to a self-managed DigitalOcean Droplet instead of a managed static platform.

Server Baseline

  • Ubuntu LTS
  • non-root deploy user
  • UFW firewall rules
  • SSH config validation and hardening checks

Serving Model

The app builds to dist/. Nginx serves those static files from /var/www/portfolio.

This setup removes runtime app-process complexity in production while keeping performance stable.

Domain and HTTPS

I pointed DNS A records for apex and www to the droplet IP, configured the Nginx server block, then issued TLS certs with Certbot and enabled HTTPS redirect.

CI/CD Automation

I added a GitHub Actions workflow that deploys on push to main.

Each run connects as deploy, pulls main, installs dependencies, builds, then syncs static output with sudo rsync --delete --chown=www-data:www-data dist/ /var/www/portfolio/.

This gives a fast, repeatable release path without manual file copying.

Validation and QA

Before each publish I verified:

  • npm run build
  • generated routes
  • nginx config (nginx -t)
  • HTTP and HTTPS responses after cert updates
  • expected ordering after sort logic changes

These checks caught issues early and reduced failed deploys.

Issues I Hit and Fixed

  • Wrong app path during deploy. Fixed by using /home/deploy/Portfolio.
  • Missing dist/ during sync. Fixed by enforcing build before rsync.
  • UFW profile error for Nginx Full. Fixed by installing Nginx before applying firewall profile.
  • SSH package-update prompts around config diffs. Kept safe local config, then validated with sshd -t.
  • GitHub Actions sudo prompt failure on chown. Replaced two-step rsync + chown with rsync --chown.

Results

The project now runs as a stable, static portfolio with typed content, deterministic builds, HTTPS, and automated VPS deployment.

Next Iteration

  1. Add a staging branch and staging droplet deploy.
  2. Add post-deploy smoke tests for key routes.
  3. Add a rollback script for previous static builds.
  4. Add endpoint and certificate monitoring alerts.