Commit graph

63 commits

Author SHA1 Message Date
e20f5a7d07
ansible: monitoring with Prometheus, Loki and Grafana
All checks were successful
deploy / ansible (push) Successful in 7m23s
Nothing watched the servers: tonight's asxpio crash loop was found by
hand. On asxpio-ops, Prometheus (30 days), Loki (14 days) and Grafana
at gf.asxp.io (WireGuard only), with blackbox_exporter probing every
service from outside and postgres_exporter. On both hosts node_exporter
and cAdvisor on the VPC address, and Alloy shipping container logs, the
journal and Traefik's access log to Loki over the VPC.

Traefik and Forgejo expose their metrics; restic and the bucket replica
record their last success for node_exporter. Dashboards: an overview as
home, plus hosts, containers, Traefik, probes, Postgres and logs.
Alerts by email to me@asxp.io: host or service down, certificate under
14 days, disk or memory nearly full, a container restart-looping,
Postgres down, backup or replica stale.

Assisted-by: Claude:opus-5-5
2026-11-13 04:59:11 +04:00
5289542dca
docker_service: skip health checks in a dry run
A dry run changes nothing to verify, and for a service that is not on
the host yet the check waited a minute and failed the dry run.

Assisted-by: Claude:opus-5-5
2026-11-13 04:59:11 +04:00
984ca66114
terraform: gf.asxp.io for Grafana
Same pattern as traefik.asxp.io: public records to asxpio-ops, so
Traefik gets a Let's Encrypt certificate over HTTP-01, while an IP
allowlist limits it to WireGuard.

Assisted-by: Claude:opus-5-5
2026-11-13 04:59:10 +04:00
d0f6bcf6cf
ansible: run the another-im/interop runner on asxpio-services
All checks were successful
deploy / ansible (push) Successful in 4m43s
The interop project kept a cx23 (claude-test-interop-runner, about
EUR 6/month with its IPv4) only to run its Forgejo runner. Run it here
instead, with the same registration (UUID + token, token in the vault)
and the same three labels, pinned.

Its PR checks are untrusted, so unlike our own runner its jobs get
neither the Docker socket nor host volumes. nix-host, which on the old
host used the host's Nix store as a cache, is a throwaway container for
now; a second, trusted-only registration can bring the cache back.

Rollback: remove the role from site.yaml (or stop the container) and
start the old runner VM again.

Assisted-by: Claude:opus-5-5
2026-11-13 03:47:17 +04:00
9e05dbf5a7
docs: runner 13 marks the self-replacing run as failed
All checks were successful
deploy / ansible (push) Successful in 4m17s
Under runner 13 the run that recreates the runner ends as failed, not
cancelled (run 450); say so, and where its log stops, so it is not
mistaken for a real failure.

Assisted-by: Claude:opus-5-5
2026-11-13 03:15:31 +04:00
428df5896d
Pin exact versions of images and actions
Some checks failed
deploy / ansible (push) Failing after 17m57s
Floating tags (17-alpine, 3.4-slim, nginx:alpine, :latest, :debug, @v7)
change under a deploy without anyone deciding it: the storage deploy
moved Postgres to 17.12 on its own because of postgres:17-alpine. Pin
every image and action to its exact current version, so each change
arrives as a reviewed Renovate PR and a rollback is a revert.

Assisted-by: Claude:opus-5-5
2026-11-13 02:46:54 +04:00
35c6ab5e30
ci: run Renovate from asxpi/renovate
Renovate now runs from its own repository for every repo, with the
shared rules in its preset. Extend that preset here and keep only what
is specific to this repo (the Ansible var manager, the Paper JRE, the
runner and nix notes); drop the workflow that ran it for iac alone.

Assisted-by: Claude:opus-5-5
2026-11-13 01:43:18 +04:00
e2ab74a6bd
docs: what a runner or nix image upgrade looks like
All checks were successful
deploy / ansible (push) Successful in 4m23s
Both recreate the runner mid-job: the applying run shows as cancelled,
leaves its job container behind, and needs a later run to confirm.
Write the follow-up steps down where the Renovate PRs send you.

Assisted-by: Claude:opus-5-5
2026-11-12 23:51:04 +04:00
1df2027383
Merge PR #6: deps: Update code.forgejo.org/forgejo/runner Docker tag to v13
Some checks are pending
deploy / ansible (push) Has started running
2026-11-12 23:43:54 +04:00
241db51756
Merge PR #3: deps: Update nixos/nix Docker tag to v2.35.6
Some checks are pending
deploy / ansible (push) Has started running
2026-11-12 23:32:57 +04:00
d22d924b45
Merge PR #5: deps: Update actions/checkout action to v7 2026-11-12 23:32:56 +04:00
a2a977e88a
Merge PR #4: deps: Update ghcr.io/xtls/xray-core Docker tag to v26.11.9 2026-11-12 23:32:56 +04:00
d5894ea645
Merge PR #2: deps: Update dev.another.im/another-im/tackygram Docker tag to v0.4.3 2026-11-12 23:32:56 +04:00
38a96497ca
ci: quote the deploy run-name
All checks were successful
deploy / ansible (push) Successful in 4m25s
The unquoted value contains ": ", which YAML reads as a nested mapping,
so Forgejo rejected the whole workflow and the last two pushes ran
nothing. Quote it.

Assisted-by: Claude:opus-5-5
2026-11-12 23:24:54 +04:00
858be6a0ef
ansible: remove the retired Minecraft Fossils role
Some checks failed
deploy.yaml / ansible: remove the retired Minecraft Fossils role (push) Failing after 0s
The server is stopped and its data deleted (archived to object storage
as minecraft-fossils-final-20261112.tar.zst), so the role that kept it
stopped has nothing left to do.

Assisted-by: Claude:opus-5-5
2026-11-12 23:24:09 +04:00
ba2e3cbde8
ci: name deploy runs "dry run" or "apply"
Some checks failed
deploy.yaml / ci: name deploy runs "dry run" or "apply" (push) Failing after 0s
Every run was listed as "deploy", so dry runs on Renovate branches
looked like deployments. Name each run after what it does and the
branch it ran on.

Assisted-by: Claude:opus-5-5
2026-11-12 23:23:57 +04:00
967ce9e5f6
terraform: drop the DNS import blocks
Both zones and all record sets are in state now; the import blocks
did their job. A plan without them shows no changes.

Assisted-by: Claude:opus-5-5
2026-11-12 23:23:56 +04:00
c4462b3e33 ci: point Renovate at asxpi/iac
The repository was renamed from infrastructure to iac. Renovate looks
repositories up by name, and with push-to-create on, the old name now
belongs to a different, empty repository.

Assisted-by: Claude:opus-5-5
2026-11-12 22:54:11 +04:00
2e9d4649a0 ansible: daily replica of the hel1 buckets into fsn1
restic writes both regions itself, but the ad-hoc backups under
asxpio-ops/, the Terraform state and the k8s state only ever went to
hel1. Sync each hel1 bucket into its fsn1 twin every morning from
asxpio-services, with rclone pinned like everything else.

restic/ is excluded, so the independent fsn1 restic repositories are
never touched. A run that would delete more than 1000 objects stops and
mails, so a mistake on the hel1 side cannot empty the replica the next
morning. The first dry run copied 23 objects and deleted none.

Assisted-by: Claude:opus-5-5
2026-11-12 22:52:13 +04:00
d5442f2c36 s3fs: turn it off on asxpio-services too
The mounts were only ever read by one-off migration scripts, and their
cache had grown to 9.7 GB. restic and the bucket replica talk to object
storage directly, so nothing needs a filesystem view of the buckets.
Disable s3fs like on asxpio-ops; the bootstrap playbook unmounts and
drops the fstab lines (applied by hand, cache removed).

The playbook still created Immich directories under the mount and its
summary needed the cache size, which a disabled host no longer sets.

Assisted-by: Claude:opus-5-5
2026-11-12 22:52:12 +04:00
32836d5d53 terraform: manage DNS for asxp.io and czsk.it
The zones were edited by hand in the console, and it showed: dozens of
records for servers that no longer existed, a CNAME and a TXT record
missing their trailing dot, and a 24 h default TTL that made today's
XMPP move linger in resolver caches.

Now both zones live in the asxpio project, so describe them here,
adopted with import blocks (the first apply imported 87 and changed
nothing but protection). Our servers' records reference hcloud_server
instead of literal IPs. Every record set is change-protected except
those of the test XMPP servers, and both zones are delete-protected.

Assisted-by: Claude:opus-5-5
2026-11-12 22:46:53 +04:00
718524ffff terraform, ansible: drop the Immich buckets
Immich has not run for months and its photos are now in Google Photos.
asxpio-immich-main and asxpio-immich-repl (about 140 GB each) are
deleted; remove them from Terraform (state too) and from the s3fs
mounts, so nothing recreates or remounts them. The mount on
asxpio-services was taken down and its fstab line removed by hand.

Assisted-by: Claude:opus-5-5
2026-11-12 22:46:44 +04:00
a5f518892d deps: Update code.forgejo.org/forgejo/runner Docker tag to v13 2026-11-12 18:35:07 +00:00
9dfb874c9f deps: Update actions/checkout action to v7 2026-11-12 18:35:04 +00:00
e8dd8e0026 deps: Update ghcr.io/xtls/xray-core Docker tag to v26.11.9 2026-11-12 18:35:03 +00:00
f8375b9a4e deps: Update nixos/nix Docker tag to v2.35.6 2026-11-12 18:34:56 +00:00
9b39cbe729 deps: Update dev.another.im/another-im/tackygram Docker tag to v0.4.3 2026-11-12 18:34:55 +00:00
e5b4f00094 restic: drop the vhpbot database hook
/opt/vhpbot-prod/bot.db is an empty directory that Docker created for a
bind mount whose file never existed; the bot keeps no database there.
The hook could only fail and mark every nightly run as failed.

Assisted-by: Claude:opus-5-5
2026-11-12 21:22:34 +04:00
2aca0d7669 ci: Renovate opens version bump PRs with release notes
Versions are pinned across the Ansible vars, compose files, Terraform
providers and workflows, and nothing says when one is behind. Run
Renovate daily from Forgejo Actions: one PR per available bump, with
the upstream changelog in the body, which the deploy pipeline then
dry-runs like any other branch.

Variables are tracked through a "# renovate: datasource=... depName=..."
comment above them; Forgejo, Traefik, the runner and its nix image get
one here. The Paper JRE tag is left alone, the runner and nix bumps
carry their manual follow-up steps in the PR, and Renovate's own image
is bumped monthly.

Needs the repository secrets RENOVATE_TOKEN (bot user with write
access) and GH_COM_TOKEN (read-only github.com token for release
notes).

Assisted-by: Claude:opus-5-5
2026-11-12 21:20:47 +04:00
59415515a1 docs: restic backups, XMPP and Xray on services, Fossils retired
Describe the new roles, how to list and restore restic snapshots, and
that the restic password must be kept outside the servers.

Assisted-by: Claude:opus-5-5
2026-11-12 21:03:10 +04:00
bc8cc75a19 terraform: XMPP and Xray ports on services, no Hetzner backups
Open the ports of the XMPP server and Xray, which move to
asxpio-services, and close 25566 with Minecraft Fossils gone.

Turn off Hetzner's server backups on both hosts: restic now backs up
the data to object storage in two regions, and the server backups cost
20% of each server's price. Disabling them deletes the existing backup
images, so apply that part only after restic has a verified snapshot.

Assisted-by: Claude:opus-5-5
2026-11-12 20:57:52 +04:00
b4d6852a80 ansible: run Xray on asxpio-services
The VLESS Reality proxy ran from a hand-installed binary on the
temporary server. Run the upstream image instead, pinned and on the
host network, with only CAP_NET_BIND_SERVICE; its config (keys and
client id) is in the vault.

Assisted-by: Claude:opus-5-5
2026-11-12 20:56:42 +04:00
f07eb95030 ansible: run the asxp.io XMPP server on asxpio-services
ejabberd, apushd and the tackygram gateway ran on the temporary
claude-test-xmpp server, a separate CX23 billed on its own. Move them
to asxpio-services, which has the room now that Fossils is gone, and
manage them like the other stacks.

Compose follows anotherim-server's compose.yaml and
compose.tackygram.yaml with CERTS=files; image versions are variables
so they can be tracked. The TURN and call addresses come from the
host's public IP instead of a hand-edited env file. Secrets, keys and
the certificate stay on the host under /opt/xmpp/state as data and are
covered by the restic backup.

Assisted-by: Claude:opus-5-5
2026-11-12 20:56:42 +04:00
2f50229428 docker_service: let the dry run of a new service pass
In check mode nothing is written, so for a service that is not on the
host yet the restart task finds no project directory and fails the
whole dry run. Ignore errors there in check mode, as the compose task
already does.

Assisted-by: Claude:opus-5-5
2026-11-12 20:55:34 +04:00
f2aa6375fd ansible: retire Minecraft Fossils
The Fabric server holds 8.8 GB of asxpio-services' 15 GB of RAM, more
than everything else on the host together, and the XMPP server moves
here next. Save the world, remove its containers and the daily restart
timer.

The world stays in /opt/minecraft-fossils/data, and in the restic
backups, until it is deleted by hand.

Assisted-by: Claude:opus-5-5
2026-11-12 20:54:22 +04:00
59b5b4468a ansible: nightly restic backups to both object storage regions
Hetzner's server backups cost 20% of each server's price and only
restore whole disks. Back up what matters with restic instead, into the
backup buckets we already pay for: one repository per host in hel1
(asxpio-backups-main) and one in fsn1 (asxpio-backups-repl), written
independently so either region alone is a full copy.

Postgres goes in as a dump, Forgejo's and vhpbot's SQLite databases
through the online backup API, Garage with a fresh metadata snapshot,
ejabberd through its own backup command. Keeps 7 daily, 4 weekly and
6 monthly snapshots, prunes on Sundays, and mails the journal when a
run fails.

restic is the pinned upstream release, so its version is tracked in
the repo like everything else. The vault gains the restic password
and the Xray config for the role that follows.

Assisted-by: Claude:opus-5-5
2026-11-12 20:53:44 +04:00
0edfda123a docs: S3 traffic costs 1.00 now
Assisted-by: Claude:fable-5-2
2026-11-12 16:24:06 +04:00
e79b5cbfd8 docs: IPv4 addresses cost 0.50 each now
Assisted-by: Claude:fable-5-2
2026-11-12 16:23:29 +04:00
8cd39224b3 docs: current prices, and why the servers must not be recreated
The cost table was a year old. Use the amounts billed now.

Both servers still cost less than the list price for their type
(3.99 against 5.49, 11.99 against 15.99). That price belongs to the
existing server, so say plainly that a reinstall has to happen in
place.

Assisted-by: Claude:fable-5-2
2026-11-12 16:23:18 +04:00
f912e9d955 terraform: require hcloud provider 1.70
With provider 1.54 the plan replaces both servers. Hetzner stopped
returning a server's datacenter, the old provider derived the location
from it, and a location that reads back empty differs from "hel1",
which forces replacement. Only prevent_destroy stood in the way.

Provider 1.70.2 reads the location properly: the same plan then shows
the two firewall updates and nothing else. The lock file is not
tracked, so raise the version constraint to keep the old provider out.

Run terraform init -upgrade once in existing checkouts.

Assisted-by: Claude:fable-5-2
2026-11-12 16:01:58 +04:00
dc0f732d0f terraform: match the servers; open only ports in use
Checked against the Hetzner project:

 - Both servers have daily backups and delete and rebuild protection
   switched on, while the code said backups = false and set no
   protection. An apply would have turned all three off and discarded
   the existing backups. Record them as they are.

 - The firewalls had drifted both ways. Live, asxpio-ops also accepts
   XMPP ports and tcp/5000, and asxpio-services accepts 80, 443, XMPP,
   TURN, Valheim and Factorio ports; none of those services run. The
   code in turn missed tcp/25566, where the second Minecraft server
   listens.

Describe the firewalls as they should be: internal networks, plus the
public ports of services that run. Public SSH to asxpio-services goes
too; sshd and iptables already refuse it from outside the VPN.

This is not applied. The next plan will show the stale ports being
closed and nothing else.

Assisted-by: Claude:fable-5-2
2026-11-12 15:52:29 +04:00
f00505a27d base: drop the one-off crontab migration
The prune job now lives in /etc/cron.d on both hosts and root's crontab
entry is gone, so the tasks that removed it have nothing left to do.

Assisted-by: Claude:fable-5-2
2026-11-12 07:01:07 +04:00
33fbc06681 base: do not fail when the prune entry is root's only cron line
grep -v exits 1 when it prints nothing, and with pipefail that failed
the task on both hosts, although the crontab had been rewritten
correctly. Ignore grep's exit status.

Assisted-by: Claude:fable-5-2
2026-11-12 06:50:37 +04:00
e76d2a022c docs: describe the servers as they are
The READMEs still tracked the 2025 migration from bare metal and listed
Grafana, Immich, Valheim, Prosody and others that do not run. Replace
them with what is deployed, how a change reaches production and where
the secrets live.

Assisted-by: Claude:fable-5-2
2026-11-12 06:47:09 +04:00
63bd67a86d ci: deploy site.yaml from Forgejo Actions
Service changes were made by hand on the servers, which is how the
repository drifted. Apply site.yaml from a workflow instead: every
push dry-runs it against production, and a push to main applies it.

Jobs run in containers on asxpio-services. asxpio-ops is reached over
the private network; the services host is reached through asxpio-ops,
because a container connecting to its own host arrives from a Docker
bridge address that sshd and the firewall refuse. Host keys are pinned
in known_hosts.

Ansible comes from a pinned nixpkgs revision so every run uses the
same version.

Assisted-by: Claude:fable-5-2
2026-11-12 06:47:09 +04:00
27ccdc4255 ansible: describe what runs on the servers
The roles had drifted far from production: Forgejo was pinned at 12
while 15.0.11 runs, its config lacked the mailer, packages and the real
database path, and several services existed only on the hosts. Running
a role would have rolled production back. Take the servers as the truth
and make the repository match them.

site.yaml now holds the desired state of both hosts:

 - base: unattended upgrades, fail2ban, Docker daemon config and the
   weekly image prune, as found on the hosts. New: a 500M journal cap,
   and the Docker systemd override is kept absent. That override once
   opened dockerd on tcp/2375 without authentication.
 - traefik: templates as deployed, version in a variable (v3.7.18).
   The Coraza WAF files are removed; no router ever used them.
 - forgejo: 15.0.11, app.ini generated from the live file with the
   secrets taken from the vault.
 - crawler_alert, sillytavern, minecraft, minecraft_fossils: added
   from the hosts.

Forgejo's canonical name becomes git.asxp.io; git.czsk.it stays as an
alias on the same router.

docker_service restarts a stack when its config changes, no longer
forces ownership on existing data directories, and hides template
diffs because they carry secrets. Containers listed in
service_graceful_stop are saved and stopped with a long timeout before
compose recreates them: Docker's 10 s default is too short for a
Minecraft world. Both game servers get stop_grace_period for the same
reason.

Removed: the full-deployment and runner playbooks with their prompts,
the Forgejo backup and restore roles for the old bare-metal host, and
inventory entries for services that were never deployed. SSH hardening
stays a manual playbook, since a mistake there locks everyone out.

New secrets (SMTP, RCON, CurseForge key) went into the vault.

Assisted-by: Claude:fable-5-2
2026-11-12 06:47:08 +04:00
e5322cbac2 terraform: never replace a running server
hcloud replaces a server when user_data, image or ssh_keys change, and
cloud-init only runs at first boot anyway. An edit to cloud-init, like
adding a user, would therefore plan the destruction of a production
machine. Ignore changes to those three attributes and set
prevent_destroy on both servers, so cloud-init describes a rebuild and
nothing else.

Add user zerrtika to cloud-init to match the servers; the user was
created on them by playbook 04.

Assisted-by: Claude:fable-5-2
2026-11-12 06:47:08 +04:00
a79b110259 forgejo-runner: match the live runner and pin the nix image
The role had drifted from what runs on asxpio-services: capacity 4,
a bridge network per workflow so service containers start, the Docker
socket automounted into jobs, and a nix-latest label backed by a
persistent nix-store volume.

That volume caused a CI outage on 2026-10-18. nixos/nix:latest moved on,
and since the image's /bin/sh and /etc/nix/nix.conf link into /nix/store,
the old volume left them dangling. Pin the image through
forgejo_runner_nix_version and name the volume after it, so a bump is
deliberate and always gets a store seeded from its own image.

Assisted-by: Claude:opus-5-5
2026-10-18 09:48:14 +04:00
0af23425f9 swap: add a swapfile role, 2 GB on asxpio-ops
The ops server has 3.7 GB RAM and no swap. Forgejo, MinIO and the apps
fit with room to spare, but a spike (a big push, git gc, parallel
image pulls) would OOM-kill a service instead of paging out.

swappiness 10, so it only swaps under real pressure.

Assisted-by: Claude:opus-5
2026-10-12 05:31:32 +04:00
66ab6d8db3 s3fs: allow turning the mounts off, off on asxpio-ops
On the 38 GB ops disk the s3fs cache reserve (ensure_diskfree) never
fit, so all three mounts failed at every boot since 2026-09-22. Backups
now go to Hetzner S3 with rclone instead.

Add s3fs_enabled (default true); when false the role unmounts the
buckets and drops their fstab entries, keeping the credentials file
that the rclone wrapper reads.

Assisted-by: Claude:opus-5
2026-10-12 05:03:11 +04:00