jpaul.io hub page
build / build (push) Successful in 17s

Single self-contained index.html (avatar + favicon inlined; only Google
Fonts external; zero JS; dark-mode; responsive) served by a baked
nginx:alpine image behind Traefik, built and published by CI on push
to main and rolled out by Watchtower.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LbhPvfSERrnuY5jdhAdB7v
This commit is contained in:
claudeandClaude Opus 4.8 committed 2026-07-02 21:11:38 -04:00
commit e3c7bf2e74
8 files changed
+719

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
name: build
# On push to main, build the self-contained nginx image and push it to the
# build registry, then publish it so the deploy host can pull it over HTTPS
# and Watchtower recreates the container. The registry address is injected
# from a masked secret so it never appears in the repo or the logs.
on:
push:
branches: [main]
paths:
- 'index.html'
- 'favicon.svg'
- 'nginx.conf'
- 'Dockerfile'
- '.gitea/workflows/build.yml'
workflow_dispatch:
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# The build registry is plain HTTP — tell buildkit not to upgrade
# the push to HTTPS.
config-inline: |
[registry."${{ secrets.REGISTRY_HOST }}"]
http = true
insecure = true
- name: Configure registry credentials for buildx
env:
REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 -w0)
cat > ~/.docker/config.json <<EOF
{"auths":{"$REGISTRY_HOST":{"auth":"$AUTH"}}}
EOF
- name: Compute tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.REGISTRY_HOST }}/justin/jpaulio
tags: |
type=raw,value=latest
type=raw,value=test-main
type=sha,prefix=test-sha-,format=long
labels: |
org.opencontainers.image.url=https://jpaul.io
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Publish package (link to repo)
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
code=$(curl -s -o /tmp/link.out -w "%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://git.jpaul.io/api/v1/packages/justin/container/jpaulio/-/link/jpaulio")
echo "link -> jpaulio: HTTP $code"
case "$code" in
201) echo "OK — newly linked" ;;
400|409) echo "OK — already linked" ;;
*) cat /tmp/link.out; exit 1 ;;
esac
+7
View File
@@ -0,0 +1,7 @@
# local scratch — the avatar is inlined into index.html as a data URI, so these
# downloaded source files aren't needed in the repo
.assets/
# Claude Code local files (skill config with internal deploy notes, local
# settings) stay out of the repo
.claude/
+7
View File
@@ -0,0 +1,7 @@
# Self-contained image for the jpaul.io hub page: nginx:alpine with the site
# and config baked in. No bind mounts, no inode surprises — the running
# container always matches the image it was built from.
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html favicon.svg /usr/share/nginx/html/
+33
View File
@@ -0,0 +1,33 @@
# jpaul.io
The personal hub landing page at the root of [jpaul.io](https://jpaul.io) — who I am,
what I make, and where to find me. Not a blog, not a portfolio: a hub.
## Contents
- **`index.html`** — the whole site. One self-contained file, no build step. The avatar
and favicon are inlined as data URIs; the only external assets are the Google Fonts
(Inter + JetBrains Mono). Zero JavaScript.
- **`favicon.svg`** — green `$` / amber `_` cursor mark on the GitHub-dark-slate ground.
- **`Dockerfile`** — bakes the site into an `nginx:alpine` image.
- **`docker-compose.yml`** — runs that image behind Traefik.
- **`.gitea/workflows/build.yml`** — CI: build the image and publish it on push to `main`.
## Design
Dark-mode only, GitHub-dark-slate palette, terminal-panel motifs. Matches the brand used
across [jpaul.me](https://www.jpaul.me), the AI Workflow course cards, and the YouTube
channel. Responsive; cards stack to one column below 720px; hero stays readable at 375px.
## Deploy — automated (push to main → live)
1. **Push to `main`** → Gitea Actions builds the `nginx:alpine` image and publishes it.
2. The deploy host **pulls** the image over HTTPS.
3. **Watchtower** recreates the container with the new image.
No bind mounts — the running container always matches what CI built. Runs behind Traefik:
`websecure` entrypoint, an HTTP-01 cert, and `Host(jpaul.io) || Host(www.jpaul.io)` with
the cert domains named explicitly (a compound rule won't auto-populate them).
First run on the host: `docker compose up -d`. After that, editing the site is just
commit → push to `main`; CI and Watchtower do the rest.
+31
View File
@@ -0,0 +1,31 @@
# jpaul.io hub page — a single container behind Traefik. The site is baked into
# the image by CI (see .gitea/workflows/build.yml + Dockerfile); Watchtower
# recreates the container when a new image is published, so what's running
# always matches what CI built.
#
# First run: docker compose up -d
# Updates: automatic — push to main -> CI builds -> Watchtower redeploys.
services:
jpaulio:
image: git.jpaul.io/justin/jpaulio:latest
container_name: jpaulio
restart: always
pull_policy: always
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.jpaulio.rule=Host(`jpaul.io`) || Host(`www.jpaul.io`)"
- "traefik.http.routers.jpaulio.entrypoints=websecure"
- "traefik.http.routers.jpaulio.tls.certresolver=myresolver"
# A compound (||) rule doesn't auto-populate the ACME domain list, so name
# the cert domains explicitly: one cert for the apex + www SAN.
- "traefik.http.routers.jpaulio.tls.domains[0].main=jpaul.io"
- "traefik.http.routers.jpaulio.tls.domains[0].sans=www.jpaul.io"
- "traefik.http.services.jpaulio.loadbalancer.server.port=80"
# Watchtower auto-updates this container when a new image is published.
- "com.centurylinklabs.watchtower.enable=true"
networks:
web:
external: true
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="jpaul.io">
<rect width="64" height="64" rx="14" fill="#0D1117"/>
<rect x="1.75" y="1.75" width="60.5" height="60.5" rx="12.25" fill="none" stroke="#30363D" stroke-width="1.5"/>
<text x="25" y="45" font-family="'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace" font-size="42" font-weight="700" fill="#00D966" text-anchor="middle">$</text>
<text x="41" y="45" font-family="'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace" font-size="42" font-weight="700" fill="#FFB020" text-anchor="middle">_</text>
</svg>

After

Width:  |  Height:  |  Size: 633 B

+527
View File
@@ -0,0 +1,527 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Justin Paul — jpaul.io</title>
<meta name="description" content="Justin Paul builds AI-native tools for infrastructure, agtech, and everyone tired of copy-pasting into a chat window. Drawbar founder, course creator, homelab operator.">
<meta name="author" content="Justin Paul">
<meta name="theme-color" content="#0D1117">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="Justin Paul — jpaul.io">
<meta property="og:description" content="AI-native tools for infrastructure, agtech, and everyone tired of copy-pasting into a chat window.">
<meta property="og:url" content="https://jpaul.io/">
<meta name="twitter:card" content="summary">
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%20role%3D%22img%22%20aria-label%3D%22jpaul.io%22%3E%0A%20%20%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%230D1117%22%2F%3E%0A%20%20%3Crect%20x%3D%221.75%22%20y%3D%221.75%22%20width%3D%2260.5%22%20height%3D%2260.5%22%20rx%3D%2212.25%22%20fill%3D%22none%22%20stroke%3D%22%2330363D%22%20stroke-width%3D%221.5%22%2F%3E%0A%20%20%3Ctext%20x%3D%2225%22%20y%3D%2245%22%20font-family%3D%22%27JetBrains%20Mono%27%2C%20ui-monospace%2C%20%27SFMono-Regular%27%2C%20Menlo%2C%20monospace%22%20font-size%3D%2242%22%20font-weight%3D%22700%22%20fill%3D%22%2300D966%22%20text-anchor%3D%22middle%22%3E%24%3C%2Ftext%3E%0A%20%20%3Ctext%20x%3D%2241%22%20y%3D%2245%22%20font-family%3D%22%27JetBrains%20Mono%27%2C%20ui-monospace%2C%20%27SFMono-Regular%27%2C%20Menlo%2C%20monospace%22%20font-size%3D%2242%22%20font-weight%3D%22700%22%20fill%3D%22%23FFB020%22%20text-anchor%3D%22middle%22%3E_%3C%2Ftext%3E%0A%3C%2Fsvg%3E%0A" type="image/svg+xml">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Inter:wght@600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap">
<style>
:root {
--bg: #0D1117;
--panel: #161B22;
--border: #30363D;
--elevated: #21262D;
--text: #c9d1d9;
--high: #F0F6FC;
--dim: #7D8590;
--green: #00D966;
--amber: #FFB020;
--blue: #6a9dff;
--code: #ffd580;
--mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
--sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--maxw: 900px;
--radius: 12px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
font-family: var(--sans);
font-weight: 600;
color: var(--text);
background-color: var(--bg);
background-image: radial-gradient(ellipse 80% 60% at 50% 50%, #161B22 0%, #0D1117 100%);
background-attachment: fixed;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.wrap {
max-width: var(--maxw);
margin: 0 auto;
padding: clamp(48px, 9vw, 96px) clamp(20px, 5vw, 40px) 64px;
}
a { color: var(--blue); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 3px; }
a:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
border-radius: 3px;
}
.mono { font-family: var(--mono); }
/* Skip link */
.skip {
position: absolute; left: -9999px; top: 0;
background: var(--elevated); color: var(--high);
padding: 10px 16px; border-radius: 8px; z-index: 10;
font-family: var(--mono); font-size: 13px;
}
.skip:focus { left: 12px; top: 12px; }
/* ---------- HERO ---------- */
.hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 40px;
flex-wrap: wrap;
}
.hero__lead {
display: flex;
align-items: center;
gap: clamp(16px, 3vw, 24px);
min-width: 0;
flex: 0 1 auto;
}
.hero__text { max-width: 380px; }
.avatar {
width: 84px; height: 84px;
border-radius: 50%;
border: 1px solid var(--border);
display: block;
flex: none;
background: var(--panel);
transition: filter .2s ease;
}
.avatar:hover { filter: brightness(1.05); }
.eyebrow {
font-family: var(--mono);
font-weight: 500;
font-size: 13px;
color: var(--dim);
letter-spacing: .01em;
margin-bottom: 6px;
}
.eyebrow::before { content: '~/'; color: var(--green); }
h1 {
font-family: var(--sans);
font-weight: 800;
font-size: clamp(34px, 6vw, 52px);
line-height: 1.04;
letter-spacing: -0.02em;
color: var(--high);
margin: 0 0 18px;
}
.tagline {
font-weight: 500;
font-size: clamp(17px, 2.4vw, 21px);
line-height: 1.5;
color: var(--text);
max-width: 30ch;
margin: 0 0 14px;
}
.subtitle {
font-family: var(--mono);
font-weight: 400;
font-size: 13.5px;
color: var(--dim);
margin: 0;
}
/* Hero terminal panel */
.term {
flex: none;
width: 260px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 8px 24px -12px rgba(0,0,0,0.6);
}
.term__bar {
display: flex; align-items: center; gap: 7px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: rgba(255,255,255,0.015);
}
.dot { width: 11px; height: 11px; border-radius: 50%; flex: none; }
.dot--r { background: #ff5f56; }
.dot--y { background: #ffbd2e; }
.dot--g { background: #27c93f; }
.term__bar .label {
margin-left: auto;
font-family: var(--mono);
font-size: 11px;
color: var(--dim);
}
.term__body {
padding: 16px 16px 18px;
font-family: var(--mono);
font-size: 13px;
line-height: 1.7;
}
.term__body .p { color: var(--green); }
.term__body .out { color: var(--code); }
.cursor {
display: inline-block;
width: 8px; height: 15px;
vertical-align: -2px;
background: var(--green);
margin-left: 2px;
}
/* ---------- generic section ---------- */
section { margin-top: clamp(48px, 8vw, 72px); }
.sec-label {
font-family: var(--mono);
font-weight: 600;
font-size: 13px;
color: var(--dim);
margin-bottom: 18px;
}
.sec-label .p { color: var(--green); }
/* ---------- NOW ---------- */
.now {
background: var(--panel);
border: 1px solid var(--border);
border-left: 2px solid var(--green);
border-radius: var(--radius);
padding: 20px clamp(20px, 3vw, 26px) 22px;
}
.now__head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
margin-bottom: 12px;
}
.now__cmd {
font-family: var(--mono);
font-weight: 600;
font-size: 14px;
color: var(--green);
}
.now__cmd::before { content: '$ '; color: var(--dim); }
.now__date {
font-family: var(--mono);
font-weight: 400;
font-size: 12px;
color: var(--dim);
white-space: nowrap;
}
.now__body {
font-weight: 500;
font-size: 15.5px;
line-height: 1.65;
color: var(--text);
margin: 0;
}
.now__body strong { color: var(--high); font-weight: 700; }
/* ---------- CARDS ---------- */
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.card {
display: flex;
flex-direction: column;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: border-color .2s ease, transform .2s ease;
}
.card:hover { border-color: #3d4650; transform: translateY(-2px); }
.card--new { border-color: rgba(0,217,102,0.35); }
.card--new:hover { border-color: rgba(0,217,102,0.6); }
.card__bar {
display: flex; align-items: center; gap: 7px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
background: rgba(255,255,255,0.015);
}
.card__kind {
margin-left: auto;
font-family: var(--mono);
font-weight: 600;
font-size: 11px;
letter-spacing: 0.06em;
color: var(--dim);
}
.tag-new { color: var(--green); }
.card__body {
display: flex;
flex-direction: column;
flex: 1;
padding: 20px clamp(18px, 2.4vw, 22px) 18px;
}
.card__title {
font-family: var(--sans);
font-weight: 800;
font-size: 20px;
letter-spacing: -0.01em;
color: var(--high);
margin: 0 0 8px;
}
.card__desc {
font-weight: 500;
font-size: 14px;
line-height: 1.55;
color: var(--text);
margin: 0 0 20px;
flex: 1;
}
.card__action {
font-family: var(--mono);
font-weight: 500;
font-size: 13.5px;
color: var(--blue);
align-self: flex-start;
}
.card__action::before { content: '\2192\00a0'; }
.card__action:hover { text-decoration: underline; text-underline-offset: 3px; }
/* ---------- ELSEWHERE ---------- */
.pills {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 0;
}
.pill {
font-family: var(--mono);
font-weight: 400;
font-size: 13px;
color: var(--dim);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 999px;
padding: 7px 14px;
transition: color .18s ease, border-color .18s ease, background .18s ease;
}
.pill:hover {
color: var(--high);
border-color: #3d4650;
background: var(--elevated);
text-decoration: none;
}
.pill b { color: var(--text); font-weight: 500; }
.pill:hover b { color: var(--high); }
.pill .sep { color: var(--border); margin: 0 2px; }
/* ---------- FOOTER ---------- */
footer {
margin-top: clamp(56px, 9vw, 88px);
padding-top: 24px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.footer__avatar {
width: 26px; height: 26px;
border-radius: 50%;
border: 1px solid var(--border);
flex: none;
opacity: 0.85;
}
.footer__line {
font-family: var(--mono);
font-weight: 400;
font-size: 12.5px;
line-height: 1.7;
color: var(--dim);
margin: 0;
}
.footer__line a { color: var(--dim); text-decoration: underline; text-underline-offset: 2px; }
.footer__line a:hover { color: var(--text); }
.footer__line .sep { color: var(--border); }
.footer__line .home { color: var(--green); }
/* ---------- RESPONSIVE ---------- */
@media (max-width: 860px) {
.term { display: none; }
.hero { gap: 0; }
}
@media (max-width: 720px) {
.cards { grid-template-columns: 1fr; }
}
@media (max-width: 440px) {
.avatar { width: 64px; height: 64px; }
.hero__lead { gap: 16px; }
.now__head { flex-direction: column; gap: 4px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
</style>
</head>
<body>
<a class="skip" href="#make">Skip to what I make</a>
<div class="wrap">
<!-- 1. HERO -->
<header class="hero">
<div class="hero__lead">
<img class="avatar" src="data:image/jpeg;base64,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 truncated
alt="Justin Paul avatar" fetchpriority="high" decoding="async">
<div class="hero__text">
<p class="eyebrow">jpaul.io</p>
<h1>Justin Paul</h1>
<p class="tagline">I build AI-native tools for infrastructure, agtech, and everyone who&rsquo;s tired of copy-pasting into a chat window.</p>
<p class="subtitle">Drawbar founder &middot; course creator &middot; homelab operator</p>
</div>
</div>
<aside class="term" aria-hidden="true">
<div class="term__bar">
<span class="dot dot--r"></span>
<span class="dot dot--y"></span>
<span class="dot dot--g"></span>
<span class="label">zsh</span>
</div>
<div class="term__body">
<div><span class="p">$</span> whoami</div>
<div class="out">[email protected]</div>
<div><span class="p">$</span> <span class="cursor"></span></div>
</div>
</aside>
</header>
<main>
<!-- 2. NOW -->
<section aria-labelledby="now-h">
<div class="now">
<div class="now__head">
<span class="now__cmd" id="now-h">now</span>
<span class="now__date">updated 2026-07-02</span>
</div>
<p class="now__body">
Publishing <strong>The Workflow</strong> &mdash; a free 27-module course on the engineering scaffolding around AI-assisted development. New post every Tuesday and Thursday on <a href="https://www.jpaul.me">jpaul.me</a> through August. <strong>Launching a <a href="https://www.youtube.com/@jpaulio">new YouTube channel</a></strong> in parallel: small AI-assisted homelab projects, weekly. Building the LMS it&rsquo;ll all run on next &mdash; live on the same channel. Running <a href="https://drawbar.io">Drawbar</a> between takes.
</p>
</div>
</section>
<!-- 3. WHAT I MAKE -->
<section id="make" aria-labelledby="make-h">
<p class="sec-label" id="make-h"><span class="p">$</span> what I make</p>
<div class="cards">
<!-- A: Course -->
<article class="card">
<div class="card__bar">
<span class="dot dot--r"></span><span class="dot dot--y"></span><span class="dot dot--g"></span>
<span class="card__kind">COURSE</span>
</div>
<div class="card__body">
<h2 class="card__title">The Workflow</h2>
<p class="card__desc">27 modules and a capstone on the engineering practice around AI-assisted coding. Free. Model- and vendor-neutral.</p>
<a class="card__action" href="https://github.com/recklessop/ai-workflow-course/wiki">Read the wiki</a>
</div>
</article>
<!-- B: Product -->
<article class="card">
<div class="card__bar">
<span class="dot dot--r"></span><span class="dot dot--y"></span><span class="dot dot--g"></span>
<span class="card__kind">PRODUCT</span>
</div>
<div class="card__body">
<h2 class="card__title">Drawbar</h2>
<p class="card__desc">Agtech data platform. Cattle records, growth tracking, and a working set of MCPs so an AI can actually talk to your herd data.</p>
<a class="card__action" href="https://drawbar.io">drawbar.io</a>
</div>
</article>
<!-- C: Channel (NEW) -->
<article class="card card--new">
<div class="card__bar">
<span class="dot dot--r"></span><span class="dot dot--y"></span><span class="dot dot--g"></span>
<span class="card__kind">CHANNEL <span class="tag-new">&middot; NEW</span></span>
</div>
<div class="card__body">
<h2 class="card__title">jpaul.io</h2>
<p class="card__desc">Weekly builds &mdash; small AI-assisted projects on Raspberry Pis, self-hosted services, and homelab hardware. Every commit follows the <a href="https://www.jpaul.me">Workflow</a> playbook.</p>
<a class="card__action" href="https://www.youtube.com/@jpaulio">Subscribe on YouTube</a>
</div>
</article>
<!-- D: Code -->
<article class="card">
<div class="card__bar">
<span class="dot dot--r"></span><span class="dot dot--y"></span><span class="dot dot--g"></span>
<span class="card__kind">CODE</span>
</div>
<div class="card__body">
<h2 class="card__title">Public repos</h2>
<p class="card__desc">Course source, MCP servers, homelab configs, small tools. Everything I write in public.</p>
<a class="card__action" href="https://github.com/recklessop">github.com/recklessop</a>
</div>
</article>
</div>
</section>
<!-- 4. ELSEWHERE -->
<section aria-labelledby="else-h">
<p class="sec-label" id="else-h"><span class="p">$</span> elsewhere</p>
<nav class="pills" aria-label="Find me elsewhere">
<a class="pill" href="https://www.jpaul.me">blog<span class="sep"> &middot; </span><b>jpaul.me</b></a>
<a class="pill" href="https://www.youtube.com/@jpaulio">youtube<span class="sep"> &middot; </span><b>@jpaulio</b></a>
<a class="pill" href="https://git.jpaul.io">git<span class="sep"> &middot; </span><b>git.jpaul.io</b></a>
<a class="pill" href="https://github.com/recklessop">github<span class="sep"> &middot; </span><b>recklessop</b></a>
<a class="pill" href="https://www.linkedin.com/in/jpaul84/">linkedin<span class="sep"> &middot; </span><b>in/jpaul84</b></a>
<a class="pill" href="https://www.jpaul.me/feed">rss<span class="sep"> &middot; </span><b>feed.xml</b></a>
<a class="pill" href="mailto:[email protected]">email<span class="sep"> &middot; </span><b>[email protected]</b></a>
</nav>
</section>
</main>
<!-- 5. FOOTER -->
<footer>
<img class="footer__avatar" src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2ODApLCBxdWFsaXR5ID0gOTAK/9sAQwADAgIDAgIDAwMDBAMDBAUIBQUEBAUKBwcGCAwKDAwLCgsLDQ4SEA0OEQ4LCxAWEBETFBUVFQwPFxgWFBgSFBUU/9sAQwEDBAQFBAUJBQUJFA0LDRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU/8AAEQgBLAEsAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A/KsnNJRRTQCg0pOBTR1pW61QhNpxmlXrSscCp7K1a4lUAcZrNuxS3JLOya6YAV0+n6f9jQGn2GmraAHHNXncEYrnbudS0GMS1Oz0oQflRWb1LTsGaKKDxU2K5hN2BTTJnpSN0xUZGD3p3BIUjJp3pSDgUuaV2aKwYoozRmlcu6AUUUYpGVg7UnSlop3Cwn4UucUlLincTiShsijGaiVqmUjFaE3sHamg4PNKeabtNKw7jyc9KgeDeaec05XOKXKHOc7q+kFR5iDk9cVhMCpwwOa9AQLJuD9D0rmtd0pklMiD5a6IvQ5pbmFxTT14qTn7pqM8E1ojJhSrSU4DFMkaetFB60UAFFFKDigBKXBPSkpyttp3GKqmRgFGTXWaFp3lxb5FxxkZrG0WyaacPjjNdiF3RIo4xWEpdDWERjvxyMD1pFGfxpCrSTeVkeuakn/clVXhsda52zqURhIU4PBpRTzFJciNLe2luZicHykLfyr0XwN+z14t8flFs7SW3ZuhmjI/nUOVjRQbPNzkU0tkGvpRP+Ce3xMmiEoubUKRnBIz/OsrUP2FfiFp2d80DY64x/jUc8e5p7Jnz0vJpxXHNeneI/2cfF/hmN2mt2l2j/lmhP8AKvPbzRtU0yZkuNKvBtOCfJbH8q1TuQ6bXQpGl+lOZwB88TxH0dSKYG83iMgVSsTysd060nFJ5ckZ+dgR7UpeM9uarQizAEE8UHjrxQuE6ClyH61VkGogwelKRjkjFISI+gJpAkj/AHyMVDsUosUOD0NLg4pMRR8dT7Gj7PfXJ/0eznlB/uRk1N0WosD3wKFJzzXR+Hfh14g16QJFp10hbu8LCvSNK/Y88ea8qvblIgenmDH9axVRXNPYvseNquetDfL16V9IWX/BPv4l3iZFxa/Qkf41ka1+xF8RNBSR7kxyxpn/AFYz/Wq513M3SZ4NgbA3am4BXIPA71r+JfBut+FNSksb7TropH/GIW2/nisUcOARiPup61amZODQ8kHGDzRKizRFG60pMPOxSMUm07PNzxnFaqVyHE5HVdP+zS5HfnFZR613OtWS3MQZBlwK4u4iMMrKwwRW0Wc00RDrTqaOtOrQzGnrRQetFABRRRQAU+Jd7getMq/pEPm3IyOKT2KW51Wg2ojtCx4Jq2G+Z8dulNUfZ4wqjt2pO2QeTXJJ6nXCIu3fDmU+WP7wru/hL8G/EHxg1mK00Wze7sy/lyyr1U1kfD/wLqHxM8QW2iadDJLMXVm8sZ+XPNfsf+zn8AtJ+Dfg+0SO2iN9cRJI8ijBVsc5rJtHbGN3Y88/Z4/Yf8NfDCzg1G8BvNUkQefb3Me4IfbNfS8Oi6TZgJaaVZwgDH7uFR/Srxk5JwSx6t61Gik/MDiuCpM9GnSXUrXcVrbR5kVU46AVhT29tqDnZDG698qK1tRuEYFHQPWUo8skx/uwewrilUdzujRTIDo2mdH021m/34lP8xWF4h+GXhvxLbvFPo9jAGGNyQLkfpXSYPrRhu7U1Xki3h42PmDxz/wT+8D+MHlnN/NayEZCQpgZ/A183ePf+Ce2u6Ssx8N21zfbc7M55r9MQQvOATT1mlAwjlfatViWYPCo/FrX/wBln4n+HHY32gyRRD+Ik/4Vw+qeDNX0XKz2pRh1BFfuff6TY6ohS+tluVPUNXOXXwY8B6k2648N2shPXcDzW/1ls53hUfh6TKmRJHtI68VNa2dzeuEt4y7H0Fftc/7Pfwwb73g+yJ7nBpYf2fvhrbMGh8K2cZ9gf8af1hk/VrH49aR8IfGmuKDZ6S0obgcf/Wr0nwf+xN8SdbuIzfaJNBbOc7hnp+Vfq3YfD/wvpAAtdGgiA6bRW8jrboFiXYoGAB2qHiXY0jhkfCngH/gmtolysc+vXt3aSDkqATz+dfRvgn9lHwT4AhjWzCX7J08+IH+ea9dd3n+8fzpqp5ZyDWLxDZosOilZeGNGsY1VNEsE28bhbqP6VYksLEH5LWGL/cQCp33OB81N8rHJNY+0Z0+xVi5piQQYAjX8q0rhbRdkctnDIZuAGQGsW2mEbjjNbiSIETzF3sfusf4a1jNnJOklsct44+Enh3xto76ddaVaQyOCDKkC7uRjrX51ftQ/sG3fw9FzrfhGOfUrIAySl8gKxycd6/T+aORMSebvY9aXUbe01zTpLG8gE1tIMPG3Q8Vupu5ySpaH8+7IbG4ntbxfKul+Vk9DUK5EflnpnNfbP7df7JEvhC8TxNoFsZre5kMssdumfKXnr+X618VRSCaM4GWBwa9CMkzzpRG7fLkXPI965XX7XN07gYBNdTETIrFuinFZ2uWwa23KM11QZxTOOXrS0mCDSk47V0o5mhp60UGikMKKKKAAVueHo904x6VhjrXT+FowGLH0qJmkNzbZ2VKVogI1CDMknCj3pIiJ9w9DXVfCfwtN4v8AiX4dsQheD7WiyY5AFcUu53wP0B/4Jx/ANNC0e28eXsAF1IDDlh2x6fjX3MXEcuz+F+axfCPhC28B+G7bRrJQtsihvlGOcCtmVFJU5yQK4JS1PUhEccqWC0inZEaEfyvvDrUN3JsjNcrZ3wRkXsm6Y1XZaWVt0hPvSnpWDZ2LQSiiioGGKKKKACiiigQUUUUDCiiigQYxRiiigAopO1KOtCZXQBwc1sWbiZFX0FY3Sr2kyfvGBPetos55o1WGwYoUY5on5Oe1Kf8AV+9dKkjlaMnxjoEfirwpqmkzIri8gaL5h61+J/7Rfwvl+DHxavtAjiMdsoL9OOSe9fuMZ/JaIkY54r4V/wCCmHwjt/8AhDpfG8MebuSVYzhe2fX8a6KUnexwV1ZXPzZQ7CyPwHOeKbfIotmVfuhacg+0qmflIHNJcJiJ1BzgV6EDx5bnCOuHP1qOp7obZyKgPQ12o556MaetFB60UiAHWg9aBwaDyaAAda6rw3grjpxXK113hqICEE+lRI0huaqx+SjEHJ9q+r/+CdPgmLxr451S4liDvYssgLfw9K+TZJDArOOQK/QD/gkxYAa14vuGA/eQ5/8AQa4Z7M9Cnuj9Ebh3crGTuIA5pZ1MWxuuBTjhSaYTu615Ej3orQGImUE8YqlqL8EZq7sGDWNqCztP+7x5f1rJ6nRFlb7r88E0rHHtUcrfOu/O8dMdKkdGeJSce9ZtG6kJRSZB6UbJevGKkoWim7sHDcfSnUAFFFFABRRRQAUUU7YtADaKcQoppYL0oAQ8CgHd0pBLG5IfPFLlAP3efxqQuKQalt2ELZzjNRKXf73ApZHigZDNnn7u3mtEjGRvwHzIwT0qQndx2qtYT+fCNv8Aq+2etWMeXz/DWyMLDZY/tBUZ2lDnmvKf2qPCK+PfhRc6XIokSPMmD04Gf6V6wG3qW7VQ8R6WureFtSTAJFvKcf8AATXTTepx10uU/AfVoPsOv6raJwILh4+PY1WbJifJycVo+MV+z/EPxRF026hMMf8AAjWfJjaa9WCs7HhS3OHv1IuGGKrDoa0NZGL0/Ss8dDXZE5Z7jaKDRTICiiigAHWux0GNmsEYVxw613Hh5wunItRM0gXFCvE0bdT619+f8Emb1W1vxjCzY2QgD9K+AgmZc8V9P/8ABPn4hDwL8QtQtWl8r+0HWMAnGelcMtmehT3R+t7nJP1pyrkVJNGFjVsYJANRRMT2ry5R1PeTViO4kKowHpWBNESPNMrDnG2tzUpYrW3d55kt0x9+Q4FfOPxp/a48J/B6yminZdWmHQ2sgbk/SkoMOeK1ue7iJlCqqeZuH3vSoWgfzCjEqF71+bdt/wAFC9RGrXFwslyLNpC0ceD8q+lepeH/APgpn4Ykt4bW90m7kuRw8pBwaTpS7Aq0e59pbIu0mDRsx/GTXz74Z/bW8EeJHRVtzbFu8j4xXrOg/FTwprsSvHrNjCWH3WnXP865+SXY61Vh3OrDY/hBpPWqy6/o8gzFqtpN6bJQanilS4TfEwkX+8pyKnlaK54vZju1FFFSWmmFFFFAw6UuPem9T0pVQscDn6U7Mm6Qpx60mRUMt9Z2rFZ7uLine truncated
<p class="footer__line">
<span class="home">~/</span>jpaul.io <span class="sep">&middot;</span> built in a weekend <span class="sep">&middot;</span> powered by <a href="https://git.jpaul.io">self-hosted Gitea</a>, <a href="https://nginx.org">nginx</a> + <a href="https://traefik.io">Traefik</a> <span class="sep">&middot;</span> &copy; 2026
</p>
</footer>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Compress the (mostly text) payload. The inlined base64 avatar doesn't
# shrink much, but the HTML/SVG/CSS around it does.
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/html text/css application/javascript image/svg+xml application/json application/xml;
# It's a two-file site. Revalidate so copy edits (git pull, no restart)
# go live on the next refresh instead of sticking in a cache.
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
try_files $uri $uri/ =404;
}
}