Skip to project
pedromartins.tech
Live
Web / Content Pipeline / CI-CD2026

Personal Portfolio

Built a terminal-inspired portfolio plus the publishing pipeline behind it: an Obsidian vault as the CMS, a GitHub Actions workflow that composes two repositories into one static artifact, and scheduled publishing without any server runtime.

Role
Designer, frontend engineer, and pipeline owner
Environment
Next.js static export · GitHub Actions · FTP host
Ownership
Visual system · Next.js architecture · Content pipeline · CI/CD workflow · Interactive terminal · Project content · Accessibility
Current pedromartins.tech home page: profile sidebar, featured work grid, and section index rail
portfolio-meta5 system boundaries
Visitor
PontoPe OS
Static Export
Deploy Workflow
2repositories composed per buildPublic site plus the private Obsidian vault
00:17nightly rebuild for scheduled postsAmerica/Sao_Paulo, so a dated note publishes itself
Staticexport deployed over FTPNo server runtime to operate or patch
11terminal commands over a virtual filesystem
01 / Interactive system

Move through the system layer by layer

portfolio-metaNext.js static export · GitHub Actions · FTP host · Read-only
Live
System map

Boundaries and responsibilities

Public or untrustedAuthenticated boundaryInternal-only dependency
READ-ONLYNext.js static export · GitHub Actions · FTP hosttool: systemselection: visitor

Visitor selected.

02 / Context

The problem behind the system

Backend and cybersecurity work is difficult to present because the most valuable parts are often invisible: boundaries, ownership, failure handling, and proof.

The portfolio combines a strong terminal identity with project-specific case studies that let a visitor inspect the system instead of translating dense prose alone.

Writing had the same problem in reverse: a blog only survives if publishing costs nothing. The answer was to make the private Obsidian vault the CMS and let a pipeline turn a committed note into a deployed page, including notes dated for the future.

Read the full project overview

This site. A statically exported Next.js 16 application that doubles as the delivery surface for everything else in this portfolio: project case studies driven by typed data, long-form security write-ups, a markdown blog, and an interactive terminal with its own virtual filesystem.

OBSIDIAN IS THE CMS - Posts are written as plain markdown notes in a private Obsidian vault that is itself a git repository (PontoPe/ObsidianGit). There is no admin panel, no database, and no headless CMS to keep online. Publishing is "commit the note". A push in the vault fires a repository_dispatch event at the website repository, and the deploy pipeline checks the vault out into portfolio/_content with a scoped token, copies any referenced images into public/blog-images, and builds the blog routes from frontmatter using gray-matter.

SCHEDULED WITHOUT A SERVER - Frontmatter carries an ISO date. Future-dated notes are filtered out of both the blog index and generateStaticParams, and dynamicParams is disabled, so an unpublished post is not merely hidden - the route does not exist. Because a static export cannot decide at request time that "today" has changed, the deploy workflow also runs on a nightly cron at 00:17 America/Sao_Paulo: a post scheduled for a given date goes live on that date even if nobody commits anything.

CI/CD - One GitHub Actions workflow composes two repositories into one artifact: checkout public site, checkout private vault, npm ci on Node 24, collect images, next build (output: "export"), then FTP the out/ directory to the host. Triggers are push to main, the vault's repository_dispatch, the nightly cron, and manual dispatch. Every third-party action is pinned to a full commit SHA, the job runs with permissions: contents: read, and persist-credentials is disabled so the checkout token is never left behind in the runner's git config.

THE TERMINAL - The homepage carries "PontoPe OS", a client-side shell over a virtual filesystem defined in lib/virtualFS.ts: ls (with -a), cd, cat, tab completion for both commands and paths, plus shortcuts that route to the blog and a set of easter eggs ending in a Konami-code BSOD. It has no shell, no eval, and no network calls - the filesystem is a static object shipped with the page, so the whole thing is a self-contained interaction rather than an attack surface.

DELIVERY - Static export with trailingSlash for the FTP host, unoptimized images, hardened response headers, and no server runtime at all: the published artifact is a directory of HTML, CSS, JS, and images.

03 / Decisions

The trade-offs that shaped the build

Decision

Obsidian is the CMS

Publishing means committing a note in a private vault repository.

Reason
A blog only survives if writing and publishing cost nothing beyond writing.
Trade-off
The site cannot build a complete blog without access to a second, private repository.
Revisit when
If posts ever need richer structure than markdown frontmatter.
Decision

Static export over a server runtime

output: export plus trailingSlash, deployed as files over FTP.

Reason
No runtime means nothing to patch, scale, or leave unpatched on a shared host.
Trade-off
Anything dynamic has to become either a build step or a client-side island.
Revisit when
Only if content genuinely requires per-request rendering.
Decision

Schedule by rebuilding, not by hiding

Future-dated posts are absent from generateStaticParams, with dynamicParams disabled, and a nightly cron re-exports the site.

Reason
A static host cannot notice that the date changed, and hiding a route client-side is not publishing control.
Trade-off
Publication granularity is a day, not an hour, and it depends on the scheduled workflow actually running.
Revisit when
If a post ever needs to land at a specific time.
Decision

The terminal is an island, not the site

PontoPe OS is progressive enhancement over ordinary links and pages.

Reason
The identity of the site should never be a prerequisite for reading it.
Trade-off
Two navigation models have to stay consistent with each other.
Revisit when
If the terminal grows commands that touch anything but local state.
Decision

One system, many identities

Case studies reuse behavior while varying visual and narrative emphasis.

Reason
Consistency builds trust, but identical pages flatten the work.
Trade-off
The content model needs disciplined project-specific profiles.
Revisit when
As new project families expose missing components.
Decision

Sanitize before publishing

Redaction is a content operation, not a CSS effect.

Reason
A security portfolio cannot leak what it claims to protect.
Trade-off
Some evidence must remain summarized instead of shown.
Revisit when
For every artifact independently.
04 / Implementation

How the decisions appear in the build

.github/workflows/deploy.yml
01# Publishing = committing a note. The vault dispatches; this workflow builds.02on:03  push: { branches: [main] }04  repository_dispatch: { types: [update_blog] }   # fired by the Obsidian vault05  schedule:06    - cron: '17 0 * * *'                          # future-dated posts go live07      timezone: "America/Sao_Paulo"08  workflow_dispatch:09 10permissions:11  contents: read                                  # least privilege for the job12 13jobs:14  web-deploy:15    runs-on: ubuntu-latest16    steps:17      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.118        with: { persist-credentials: false }      # no token left in .git/config19 20      - name: Get private content (Obsidian)21        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.122        with:23          repository: PontoPe/ObsidianGit         # private vault = the CMS24          token: ${{ secrets.GH_PAT }}25          path: portfolio/_content26          persist-credentials: false27 28      - run: npm ci && npm run build              # next build -> static export
Representative / sanitized excerpt

Two repositories, one artifact: the private Obsidian vault is checked out into the site, then the static export is shipped. Actions pinned by SHA, read-only permissions, credentials never persisted.

Next.js 16React 19TypeScriptTailwind CSS 4GitHub ActionsObsidian + Markdown
05 / Evidence

Proof, source, and inspectable outcomes

06 / Operations

The unhappy path is part of the design

01

What if the vault push does not trigger a build?

The post waits, and the site keeps serving the previous deploy.

Signal
No workflow run appears after the dispatch event.
Response
The nightly cron picks the post up anyway, and workflow_dispatch forces it immediately.
02

What if the private content checkout fails?

The build continues and produces a site with no blog posts rather than a broken deploy.

Signal
The workflow log reports a missing _content directory and the blog index comes out empty.
Response
Rotate or repair the vault token, then re-run the job before the next scheduled rebuild.
03

What if a post is dated in the future?

It is excluded from the index and from the generated routes, so the URL 404s until its date.

Signal
The route is missing from the export while the note exists in the vault.
Response
Nothing: the nightly rebuild publishes it on the date the frontmatter asked for.
04

What if a project profile is incomplete?

Typed build validation surfaces missing required content.

Signal
The production build fails before the route is published.
Response
Add verified content or deliberately omit the unsupported tool.
07 / Security

Threats, controls, and what remains

No control is presented as total risk elimination.

Supply chain

A third-party action could read job secrets

Control
Every action pinned to a full commit SHA, permissions: contents: read, and persist-credentials disabled on both checkouts
Residual risk
The vault token still grants read access to private content while the job runs, so it needs periodic rotation.
Confidentiality

Private notes leak into a public build

Control
Only markdown in the vault's posts directory is rendered, images are copied by explicit extension, and future-dated notes are excluded from the export
Residual risk
Anything committed to the posts directory is publishable by definition; review happens before the commit, not after.
Credentials

Deploy credentials sit in the pipeline

Control
FTP host, user, and password live only as repository secrets and never enter the repository or the exported artifact
Residual risk
The host authenticates by password; key-based transport would be a real improvement.
Client trust

The terminal invites people to poke at it

Control
The filesystem is a static object with no shell, no eval, and no network calls, so commands only read shipped strings
Residual risk
Anything placed in that filesystem is public content, easter eggs included.
08 / Results

What the project demonstrates

Publishing a post costs one commit in Obsidian and nothing else

Scheduled writing goes live on its own date without a server or a database

Two repositories, one of them private, build into a single static artifact with least-privilege credentials

Invisible engineering work becomes inspectable through case studies that share one system

What worked
  • Making the writing tool the CMS removed the reason blogs go stale.
  • A nightly rebuild turned scheduling into a solved problem on static hosting.
  • The terminal identity carries across very different project families.
Next iteration
  • Move deployment to key-based transport instead of FTP passwords
  • Automated link and markdown checks before the build ships
  • Automated visual regression checks and per-project social cards
Professional signal
  • CI/CD and secure pipeline design
  • Content architecture
  • Design systems
  • Accessible frontend engineering