Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
8.2 KiB
Deploying portfolio-next
Two containers on the homelab, both behind nginx-proxy: the new site (lucaswinther.info) and the
frozen 2021 site (old.lucaswinther.info). The files in deploy/ are examples shaped for the
homelab config repository (server/homelab/<stack>/ payload + ansible/playbooks/<stack>.yml,
secrets from OpenBao through compose_stack). This repo never touches the config repo: copy
the files across yourself.
| File | Goes to (config repo) |
|---|---|
deploy/compose.yml |
server/homelab/portfolio-next/docker-compose.yml |
deploy/.env.example |
server/homelab/portfolio-next/.env.example |
deploy/portfolio-next.yml |
ansible/playbooks/portfolio-next.yml |
deploy/compose.old-site.yml |
server/homelab/old-site/docker-compose.yml (change the volume to ./site, and copy archive/2021/ there as site/) |
deploy/compose.local.yml |
stays here: local try-out, see the end |
For the old site, also add a small playbook like portfolio-next.yml (no secrets, no data/), and add
both stacks to site.yml and the payload table in server/README.md like the other stacks.
What runs
- Image
gitea.lucaswinther.info/lucasw89/portfolio-next:latest, pushed by this repo's CI on everymaincommit together with:<sha>. Runs as uid 1000, plain HTTP on 8080, entrypointsite serve. - It polls
CONTENT_REPO(portfolio-next-content) everyPOLL_INTERVAL(default1h). A new commit is validated and built in the background, then swapped live. A broken commit never replaces the live site. - Ghost posts, Trakt and Hardcover are fetched during each build, at most every
SOURCES_INTERVAL(default6h). If a source is down or its token has expired, the build still succeeds with the last cached data and/statusnames the failing source. data/(bind-mounted at/data) holds the last builds,live, the source caches andstate.json. State, not payload: losing it costs one rebuild.old-siteisnginx:alpineservingarchive/2021/, a frozen copy of the 2021 site.
Environment
| Variable | Value / source |
|---|---|
CONTENT_REPO |
https://gitea.lucaswinther.info/lucasw89/portfolio-next-content.git |
CONTENT_BRANCH |
main |
CONTENT_REPO_TOKEN |
secret content_repo_token |
POLL_INTERVAL / SOURCES_INTERVAL |
1h / 6h |
GHOST_URL |
https://blog.lucaswinther.info |
GHOST_CONTENT_KEY |
secret ghost_content_key |
TRAKT_CLIENT_ID / TRAKT_USER |
secret trakt_client_id / lucasw89 |
HARDCOVER_TOKEN |
secret hardcover_token |
VIRTUAL_HOST, LETSENCRYPT_HOST |
lucaswinther.info |
SOURCES_FIXTURES=<dir> (used by the smoke test and compose.local.yml) reads the fixture JSON in
fixtures/sources instead of calling the services. Do not set it in production.
Prerequisites
- Bot user and token: a Gitea user that is a read-only collaborator on
portfolio-next-contentand nothing else, with an access token scopedread:repository. - Ghost Content API key (Ghost admin, Integrations), Trakt client id, Hardcover API token.
- Registry access on the host for this Gitea's registry.
Setup
-
Store the secrets in OpenBao at
kv/stacks/portfolio-next, fieldscontent_repo_token,ghost_content_key,trakt_client_id,hardcover_token(config repodocs/secrets.md, Adding a secret):ssh deploy@homelab docker exec -it openbao bao login read -rs A; read -rs B; read -rs C; read -rs D docker exec -i openbao bao kv put kv/stacks/portfolio-next \ content_repo_token="$A" ghost_content_key="$B" trakt_client_id="$C" hardcover_token="$D" unset A B C D -
Add the payload and playbooks to the config repo using the table above.
-
Deploy the old site first (
old.lucaswinther.info), then the new one (see the cutover below), e.g.ansible-playbook playbooks/portfolio-next.yml(add-e pull=alwayson the first run). -
Verify (first build takes a minute or two; until then pages answer 503 "No build yet"):
curl -s https://lucaswinther.info/status git ls-remote https://gitea.lucaswinther.info/lucasw89/portfolio-next-content.git refs/heads/mainliveCommitequals the commitls-remoteprints, andlastErrorisnull.sourceshasghost,traktandhardcover, eachok: true. A failing one shows its error and the time of its last good fetch; the site keeps serving the cached data.docker psshowsportfolio-nextashealthy.
DNS cutover checklist
lucaswinther.info currently serves the 2021 site, and the blog lives at blog.lucaswinther.info
(Ghost). Do these in order and check each step before the next.
- Run
scripts/archive-old-site.shifarchive/2021/is stale; checkgit status, commit. - DNS: add
old.lucaswinther.infopointing at the homelab (same target as the current site). - Deploy the
old-sitestack. Openhttps://old.lucaswinther.info/: projects, images and the three report PDFs load. - Deploy
portfolio-nextwithout moving the apex yet: temporarily setVIRTUAL_HOSTtonew.lucaswinther.info(and add that DNS record), or test with a hosts-file entry. Verify/statusas above, plus/,/work/,/off-the-clock/. - Move
lucaswinther.infoto the new container: stop whatever serves the 2021 site on the apex, setVIRTUAL_HOST/LETSENCRYPT_HOST: lucaswinther.info, redeploy, wait for the certificate.curl -I https://lucaswinther.info/must show 200 from the new site. - Confirm the new site's links to the 2021 archive point at
https://old.lucaswinther.info/. - Later, once stable: blog redirects. Old post URLs (e.g.
lucaswinther.info/blog/...or the former blog paths) get a 301 to the same post onhttps://blog.lucaswinther.info/(nginx-proxyvhost.dlocation files, or Ghost redirects). List the old URLs from the 2021 site and the blog's own history before writing them; this repo does not know them. - Rollback at any point: set
VIRTUAL_HOSTback to the old container and redeploy; the archive stays up atold.lucaswinther.info.
Day to day
| Task | How |
|---|---|
| Publish a content change now | docker exec portfolio-next site refresh (otherwise within POLL_INTERVAL). |
| Update the app | Merge to main; once CI is green, deploy with -e pull=always. A new app version rebuilds the current content. |
| Roll back the app | Pin image: to ...portfolio-next:<sha> of a good commit and deploy. |
| Rotate a secret | Update it in OpenBao (same bao kv put, all four fields), deploy (re-renders .env, recreates the container). |
| Refresh the 2021 archive | scripts/archive-old-site.sh [--force], commit, copy to the config repo. |
Troubleshooting
Start with /status (lastError, sources), then docker logs portfolio-next.
| Symptom | Cause | Fix |
|---|---|---|
could not read Username for 'https://…' |
Content repo token missing, wrong or expired. | Check content_repo_token; issue a new one. |
remote: Repository not found |
The bot cannot see the repo. | Add it as read collaborator on portfolio-next-content. |
A source shows ok: false |
Its token expired or the service is down. | Rotate the token; meanwhile cached data is served. |
| Pages answer 503 "No build yet" | No successful build so far. | Read lastError. |
lastError lists file → field: message |
The content commit is invalid; the previous build keeps serving. | Fix the content; push. |
EACCES under /data |
data/ is not owned by uid 1000. |
Re-run the playbook or chown -R 1000:1000 data. |
Compose refuses to start: ... is missing a value |
.env lacks a secret. |
Step 1, then deploy again. |
Local try-out
No token, proxy or secrets: the site polls a git repository made from the synthetic fixture content
and reads fixtures/sources instead of the network.
docker compose -f deploy/compose.local.yml up --build # SITE_PORT=18080 to use another port
# http://localhost:8080/ and /status
docker compose -f deploy/compose.local.yml down -v
Image check, as CI runs it: docker build -t portfolio-next:local . && scripts/smoke-test.sh portfolio-next:local.
The frozen 2021 site can be looked at with bun src/cli.ts preview archive/2021 4380.