There is no CI, so a git push deploys nothing - the image has to be built and the stack restarted separately. This captures that as one command rather than a sequence to remember. The image is built on the LXC and tagged with the registry name the compose file already references, so compose finds it locally and never pulls. That means no registry credentials are needed on either machine. Also records the two things that cost the most time to work out: the public hostname resolves to the reverse proxy rather than the container (192.168.2.169), and the live stack runs from /root/drinktracker while the checkouts under /home/drinkadmin are stale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploying drinktracker
./deploy/deploy.sh # typecheck, push, build on the LXC, restart, verify
./deploy/deploy.sh --yes # skip the confirmation prompt
./deploy/deploy.sh --push # also publish the image to the Gitea registry
Where things actually live
| Production LXC | 192.168.2.169 (hostname drinktracker) |
| Deploy user | drinkadmin, key ~/.ssh/drinktracker_ed25519 |
| Live compose dir | /root/drinktracker |
| Image tag | 192.168.2.140:3000/jpscott84/drinktracker:latest |
| App port | 3000 (host network), unauthenticated requests return 307 → /login |
Two traps worth knowing:
drinktracker.tenseconddelay.net is the reverse proxy (192.168.2.172), not the container. NEXTAUTH_URL points at the public name, so resolving it and SSHing there fails confusingly — the key looks rejected because it's a different machine.
The two checkouts under /home/drinkadmin/ are stale. Only /root/drinktracker is live; that's what the running container's compose labels point to.
How it works
The image is built on the LXC and tagged with the registry name that docker-compose.prod.yml already references. Compose finds it locally and never pulls, so no registry credentials are needed on either machine. --push publishes it as well, which is only useful if something else pulls that tag.
There is no CI, so git push on its own deploys nothing — it only updates the Gitea repo. The build and restart have to happen separately, which is what this script is for.
One-time setup
The script needs passwordless sudo on the LXC, because the deployment lives under /root. On the container as root:
echo 'drinkadmin ALL=(root) NOPASSWD: ALL' > /etc/sudoers.d/drinkadmin-deploy
chmod 440 /etc/sudoers.d/drinkadmin-deploy
visudo -c
This grants less than it appears to: drinkadmin is already in the docker group, and anyone who can talk to the Docker socket can mount the host filesystem into a privileged container, so they are root-equivalent already. If you'd rather not, drop the sudoers file and run the deploy steps by hand from a root shell.
Alternative worth considering: move the deployment out of /root into a drinkadmin-owned directory and skip sudo entirely. Keep the directory basename drinktracker so the compose project name — and therefore the existing container and volume names — stay the same. .env.production must be copied across intact or Postgres will reject the app's credentials, since the volume was initialised with the current password.
Verifying by hand
ssh -i ~/.ssh/drinktracker_ed25519 drinkadmin@192.168.2.169
sudo docker compose -f /root/drinktracker/docker-compose.prod.yml ps
sudo docker logs drinktracker-app-1 --tail 50
sudo docker logs drinktracker-app-1 2>&1 | grep '\[switchboard\]' # AI routing + cost per call
Each AI call logs one [switchboard] feature=… model=… cost=… latency_ms=… line. FAILOVER warnings are expected — the gateway's :batch model variants currently fail on every request and fall back.
Rollback
git revert <bad-sha> && ./deploy/deploy.sh
Or directly on the LXC, without touching git history:
sudo git -C /root/drinktracker reset --hard <previous-sha>
cd /root/drinktracker
sudo docker build -t 192.168.2.140:3000/jpscott84/drinktracker:latest .
sudo docker compose -f docker-compose.prod.yml up -d --no-build
Note the migrate service runs prisma db push --accept-data-loss on every start, so a rollback that removes a column will drop it and its data. Take a database dump first if the schema changed.