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:
@@ -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
|
||||||
@@ -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/
|
||||||
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because one or more lines are too long
+23
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user