Files
portfolio-next/docs/DEPLOY.md
T

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 every main commit together with :<sha>. Runs as uid 1000, plain HTTP on 8080, entrypoint site serve.
  • It polls CONTENT_REPO (portfolio-next-content) every POLL_INTERVAL (default 1h). 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 (default 6h). If a source is down or its token has expired, the build still succeeds with the last cached data and /status names the failing source.
  • data/ (bind-mounted at /data) holds the last builds, live, the source caches and state.json. State, not payload: losing it costs one rebuild.
  • old-site is nginx:alpine serving archive/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-content and nothing else, with an access token scoped read: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

  1. Store the secrets in OpenBao at kv/stacks/portfolio-next, fields content_repo_token, ghost_content_key, trakt_client_id, hardcover_token (config repo docs/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
    
  2. Add the payload and playbooks to the config repo using the table above.

  3. 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=always on the first run).

  4. 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/main
    
    • liveCommit equals the commit ls-remote prints, and lastError is null.
    • sources has ghost, trakt and hardcover, each ok: true. A failing one shows its error and the time of its last good fetch; the site keeps serving the cached data.
    • docker ps shows portfolio-next as healthy.

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.

  1. 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; check git status, commit. (The script's default is https://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.)
  2. DNS: add old.lucaswinther.info pointing at the homelab (same target as the current site).
  3. Deploy the old-site stack. Open https://old.lucaswinther.info/: projects, images and the three report PDFs load.
  4. Deploy portfolio-next without moving the apex yet: temporarily set both VIRTUAL_HOST and LETSENCRYPT_HOST to new.lucaswinther.info (and add that DNS record), or test with a hosts-file entry. Verify /status as above, plus /, /work/, /off-the-clock/.
  5. Move lucaswinther.info to the new container: stop whatever serves the 2021 site on the apex, set VIRTUAL_HOST/LETSENCRYPT_HOST: lucaswinther.info, redeploy, wait for the certificate. curl -I https://lucaswinther.info/ must show 200 from the new site.
  6. Confirm the new site's links to the 2021 archive point at https://old.lucaswinther.info/.
  7. 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-proxy vhost.d location 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.
  8. Rollback at any point: set VIRTUAL_HOST back to the old container and redeploy; the archive stays up at old.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.