10 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 |
LISTENBRAINZ_USER |
stereotypicalcat (not a secret; without it the Listening cell is left out) |
VIRTUAL_HOST, LETSENCRYPT_HOST |
lucaswinther.info |
SITE_URL |
https://lucaswinther.info, the image's default: nothing to set. The public origin (no path) for canonical URLs, link previews, /sitemap.xml and the feeds. Set it if the site moves; unset (empty) leaves all of those out. An invalid value fails the build (shown in /status). |
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.
Sitemap and feeds
With SITE_URL set, each build writes /sitemap.xml and three Atom feeds: /feed.xml (everything),
/work/feed.xml and /off-the-clock/feed.xml; every page links its feed in the head. There is no robots.txt in
the build because Cloudflare serves a managed one. Tell search engines about the sitemap once: submit
https://lucaswinther.info/sitemap.xml in Google Search Console (and Bing Webmaster Tools), or add the line
Sitemap: https://lucaswinther.info/sitemap.xml to the robots.txt in Cloudflare if it allows custom lines.
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.
- If
archive/2021/is stale, refresh it while the 2021 site is still on the apex:OLD_SITE_URL=https://lucaswinther.info scripts/archive-old-site.sh; checkgit status, commit. (The script's default ishttps://old.lucaswinther.info, and it refuses to write unless the page is the 2021 Create React App build, so it can never archive the new site.) - 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 set bothVIRTUAL_HOSTandLETSENCRYPT_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 (spec §6.2). Old post URLs on
blog.lucaswinther.info/<slug>/get a 301 to the same post on the new site,https://lucaswinther.info/work/writing/<slug>/(nginx-proxyvhost.dlocation files for the blog host, or Ghost redirects). Off-the-clock posts live at/off-the-clock/<slug>/instead. List the old URLs from 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). On an unchanged commit it rebuilds anyway, refetching Ghost, Trakt and Hardcover (e.g. after rotating a token). |
| 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. |
| Bump the content contract | App first, then content. Deploy the app version that supports the new contract before pushing content that declares it; an older app refuses it ("unsupported contract") and keeps serving its last good build. Contract 2 (an interest list) needs the app from spec 002 on. |
| Rotate a secret | Update it in OpenBao (same bao kv put, all four fields), deploy (re-renders .env, recreates the container). |
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. |
| Ghost/Trakt/Hardcover data stops updating | While a broken commit sits at the branch head, the live build is not rebuilt, so its sources are not refreshed either. | Fix the content; the next build refetches. |
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.