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
deployuser - 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-steprsync + chownwithrsync --chown.
Results
The project now runs as a stable, static portfolio with typed content, deterministic builds, HTTPS, and automated VPS deployment.
Next Iteration
- Add a staging branch and staging droplet deploy.
- Add post-deploy smoke tests for key routes.
- Add a rollback script for previous static builds.
- Add endpoint and certificate monitoring alerts.