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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbhPvfSERrnuY5jdhAdB7v
This commit is contained in:
2026-07-02 21:11:38 -04:00
commit e3c7bf2e74
8 changed files with 719 additions and 0 deletions
+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
File diff suppressed because one or more lines are too long
+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;
}
}