commit e3c7bf2e74015a016a3355b0f21852e9e5fcad53 Author: claude Date: Thu Jul 2 21:11:38 2026 -0400 jpaul.io hub page 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 Claude-Session: https://claude.ai/code/session_01LbhPvfSERrnuY5jdhAdB7v diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..bc8bfb9 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 < jpaulio: HTTP $code" + case "$code" in + 201) echo "OK — newly linked" ;; + 400|409) echo "OK — already linked" ;; + *) cat /tmp/link.out; exit 1 ;; + esac diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da0d4a1 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f93eeed --- /dev/null +++ b/Dockerfile @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3de3252 --- /dev/null +++ b/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7fb94d7 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..e651a89 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,6 @@ + + + + $ + _ + diff --git a/index.html b/index.html new file mode 100644 index 0000000..a1ce7a9 --- /dev/null +++ b/index.html @@ -0,0 +1,527 @@ + + + + + + Justin Paul — jpaul.io + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ Justin Paul avatar +
+

jpaul.io

+

Justin Paul

+

I build AI-native tools for infrastructure, agtech, and everyone who’s tired of copy-pasting into a chat window.

+

Drawbar founder · course creator · homelab operator

+
+
+ + +
+ +
+ +
+
+
+ now + updated 2026-07-02 +
+

+ Publishing The Workflow — a free 27-module course on the engineering scaffolding around AI-assisted development. New post every Tuesday and Thursday on jpaul.me through August. Launching a new YouTube channel in parallel: small AI-assisted homelab projects, weekly. Building the LMS it’ll all run on next — live on the same channel. Running Drawbar between takes. +

+
+
+ + +
+

$ what I make

+
+ + +
+
+ + COURSE +
+
+

The Workflow

+

27 modules and a capstone on the engineering practice around AI-assisted coding. Free. Model- and vendor-neutral.

+ Read the wiki +
+
+ + +
+
+ + PRODUCT +
+
+

Drawbar

+

Agtech data platform. Cattle records, growth tracking, and a working set of MCPs so an AI can actually talk to your herd data.

+ drawbar.io +
+
+ + +
+
+ + CHANNEL · NEW +
+
+

jpaul.io

+

Weekly builds — small AI-assisted projects on Raspberry Pis, self-hosted services, and homelab hardware. Every commit follows the Workflow playbook.

+ Subscribe on YouTube +
+
+ + +
+
+ + CODE +
+
+

Public repos

+

Course source, MCP servers, homelab configs, small tools. Everything I write in public.

+ github.com/recklessop +
+
+ +
+
+ + +
+

$ elsewhere

+ +
+
+ + + + +
+ + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..7217fab --- /dev/null +++ b/nginx.conf @@ -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; + } +}