Files
portfolio-next/docs/DEPLOY.md
T

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 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
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-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. Run scripts/archive-old-site.sh if archive/2021/ is stale; check git status, commit.
  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 VIRTUAL_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. Old post URLs (e.g. lucaswinther.info/blog/... or the former blog paths) get a 301 to the same post on https://blog.lucaswinther.info/ (nginx-proxy vhost.d location 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.
  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).
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.