Compare commits

..
12 Commits
Author SHA1 Message Date
Bastian de BylandClaude Opus 5 ea98d1b556 fix: strip the server-only log stack script that breaks client world load
The dedicated server (app 380870) and the client are packaged separately and
have drifted. Base.Log_Stack_01 was removed from the client in 42.16, but the
server package still ships entity_logstack.txt.

The server registers every script it can see into a new world's
WorldDictionary, so the entry lands in the save, and every client then fails at
world load with:

  WorldDictionaryException: [SpriteConfigs] Missing dictionary script on
  client: Base.Log_Stack_01

Nothing in multiplayer can legitimately reference a script that no client has,
so dropping it server-side costs nothing.

The strip runs after the SteamCMD update rather than once at install time on
purpose: validate restores the file on every boot, so it has to be removed on
every boot too.

Note this does not repair a world whose dictionary already records the entry --
that save still needs regenerating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:36:36 -04:00
Bastian de BylandClaude Opus 5 62c4015410 fix: let systemd actually notice when the Zomboid container exits
`@bot restart` saves, sends RCON quit, and relies on the unit's Restart=always
to bring the server back. It never came back. The container sat at exited(0)
while the unit reported active/running with NRestarts=0, so the bot waited for
a startup that was never going to happen and the server stayed down until
someone noticed.

podman generate systemd emits Type=forking with ExecStart=podman start and a
PIDFile pointing at conmon. podman start returns immediately, so the process
systemd was told to supervise was never its child -- it warns about exactly
this in the journal, once per poll, and then does not notice the exit:

  zomboid.service: Supervising process 2431312 which is not our child.
  We'll most likely not notice when it exits.

That PIDFile is also stale by design: it embeds the container ID, so every
deploy that recreates the container leaves it pointing at nothing.

Type=simple with `podman start -a` keeps podman in the foreground as systemd's
own child, so the exit is seen and Restart=always does what it always claimed
to. stdout and stderr are discarded deliberately -- attaching re-emits the
container's output for systemd to capture straight back into the journal, which
is the flood the k8s-file driver exists to prevent. podman logs still has it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:43:53 -04:00
Bastian de BylandClaude Opus 5 32748d1786 fix: stop the query filter banning every player who tries to connect
The anti-scanner rules dropped 53-byte Steam query packets above 1/hour with a
burst of 2, per source IP, on the theory that only scanners send queries and
that real players would already have been marked verified by the priority-2
rule when they sent something larger.

That premise is inverted. A client's first contact with the server IS a 53-byte
query, so nobody can be verified before querying, and nobody can query more
than twice an hour without being dropped. The counters said so plainly: five
packets had ever matched the verified-accept rule, against 30,563 drops.
fail2ban then banned each dropped player for a week -- 32 live bans, 550 total,
firing every ten minutes, every one of them a residential address.

Keeps the shape of the protection and moves the threshold somewhere no real
client reaches: opening the server browser or retrying a connection is a
handful of queries, a flood is thousands. Removes the old rules first so hosts
carrying them converge instead of stacking a second copy.

The bans themselves were also hooked into INPUT for tcp only, so they never
blocked the UDP game traffic they were meant to -- the drop rule was doing all
the damage on its own. Cleared the outstanding 32.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:26:45 -04:00
Bastian de BylandClaude Opus 5 a4bb78585b chore: bump skudak Nextcloud to 34.0.3 and BookStack to 26.5.4
Nextcloud follows the personal instance, which is now verified on 34.0.3:
`occ upgrade` completed, needsDbUpgrade is false, and cloud.debyl.io reports
34.0.3.2 out of maintenance mode.

BookStack skips 26.4 -- 26.3.4 is simply where this instance sat. Note the tag
is 26.5.4, not 26.05.4: solidnerd drops the month's leading zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:18:43 -04:00
Bastian de BylandClaude Opus 5 ad8a5392d6 chore: bump personal Immich to v3.1.0 and personal Nextcloud to 34.0.3
Immich moves the server and machine-learning images only; the Postgres and
Redis images stay on their pinned digests, which are tied to the vectorchord
extension version rather than the app release. v3.1.0 is a quality-of-life
release -- its one breaking change is dropping iOS 14 in the mobile client,
which does not touch the server.

Nextcloud 34.0.2 -> 34.0.3 is a patch release; the official image's entrypoint
runs `occ upgrade` itself on first start with a newer version.

Skudak's Nextcloud is deliberately left on 34.0.2 until this one is verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:08:22 -04:00
Bastian de BylandClaude Opus 5 91d9f6b419 fix: actually bound the Zomboid log archive, which logrotate could not
The logrotate config added with the Sophie overhaul claimed to bound PZ's own
logs. It did not. data/Logs had reached 18 GB across 112 entries and 146k
files, and rotation had never once fired there.

Two reasons, both wrong assumptions on my part. PZ rolls its logs into dated
directories -- logs_2025-12-14/ through logs_2026-08-26/, up to 279 MB each --
so the Logs/*.txt glob only ever matched a handful of loose files at the top.
And it rotates by size: not one file in that tree exceeds 100M, because the
growth is in the number of files, not the size of any of them.

Age-based pruning is the right tool, so this adds a daily zomboid-log-prune
timer keeping 14 days and deleting the emptied directories behind it. It runs
under podman unshare, since the files belong to the container's UID.

logrotate keeps server-console.txt, which is a single ever-growing file and
genuinely is what it is good at. Narrowed its scope to say so rather than
implying coverage it never had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:34:30 -04:00
Bastian de BylandClaude Opus 5 a0421226a2 fix: correct four stale mod IDs in the Sophie preset
On 42.20.3 the server loaded 279 of the preset's 283 mods and reported the
other four as "required mod ... not found". None of it was a download problem:
all 256 workshop items fetched, and every one of these four is in the preset's
own WorkshopItems= list. Only the ID each is referenced by has drifted.

Checked against the id= field of the mod.info files on disk rather than folder
names, which are unrelated to mod IDs -- an early look at folder names was
misleading here.

  FWOBenchPress&Treadmill  ->  FWOBenchPressTreadmill  (stray ampersand)
  Ladders42131             ->  Ladders4220             (per-build variants;
                                                        42131 is the 42.16 one)
  ServingPlatesB42         ->  ServingPlates42
  NewMusic_OrchestraMix    ->  NewMusic_CMM            (renamed upstream to
                                                        "Classical Music Mix")

Kept as a rename map applied at template time rather than edited into the
vendored list, so the list stays a faithful copy of upstream and re-vendoring
a newer preset keeps the corrections.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:23:31 -04:00
Bastian de BylandClaude Opus 5 4d86a57d75 fix: stop the Zomboid entrypoint withholding the admin password
The flag was gated on the admin database not existing yet, treating the file's
presence as proof the admin user had been created. It is not: PZ creates the
DB file in ServerWorldDatabase.create() before it creates the admin user, so a
boot interrupted between the two leaves a database with no admin in it.

After that the guard withheld -adminpassword on every subsequent start, the
server fell back to an interactive "Enter new administrator password:" prompt,
read EOF because a detached container has no stdin, and died with
NoSuchElementException. systemd restarted it into exactly the same state, 50
times, which is how this was found.

Passing it unconditionally costs nothing when the admin already exists and
removes the deadlock entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:10:51 -04:00
Bastian de BylandClaude Opus 5 a90f4b7ed0 fix: force the Zomboid branch switch with an explicit -beta public
Dropping -beta from app_update does not leave a beta branch. SteamCMD wrote
"public" into the manifest's UserConfig and left MountedConfig on "unstable",
so the install stayed on the 42.14.1 build from February while reporting
success. The config had already moved to the bare mod-ID syntax that 42.20+
wants, and 42.14.1 still requires the backslash prefix, so all 283 mods
loaded as "required mod ... not found" and the server came up vanilla.

Naming the branch explicitly is what actually remounts the content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:57:40 -04:00
Bastian de BylandClaude Opus 5 81451247b6 fix: drop the redundant daily from the Zomboid logrotate config
logrotate warns that size overrides daily, and it is right: with both set the
time directive does nothing. The daily timer is when the config gets checked;
100M is when it rotates. Saying so in a comment rather than in a directive
that has no effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:48:28 -04:00
Bastian de BylandClaude Opus 5 d34c6cd36d rebuild the Zomboid server on the Sophie 42 preset as "debbzoid"
The deployment had drifted badly. It was pinned to the Steam `-beta unstable`
branch, which stopped being right the moment B42 became the default at 42.20,
and it wrote every mod ID with the `\` prefix that B42 required only during
that unstable period and now rejects. Three server profiles were carried
around (vanilla, modded, b42revamp) whose mod lists were hand-curated blobs
from a Steam collection that has since moved on.

Moves to the Sophie 42 community preset, vendored from GerDeathstar/sophie-pz
("Sophie 42 Files.zip", 2026-07-31 == the b42 release tag): 283 mod IDs, 256
workshop items, map_distanciado over Muldraugh, plus its SandboxVars,
spawnregions and spawnpoints. Sophie's gameplay settings are kept exactly as
shipped -- PVP with its damage modifiers, the safety system, MaxPlayers=32,
PauseEmpty, no sleep, safehouses off. Only the keys this deployment actually
owns are templated over the top.

The modlist is stored as YAML lists rather than the INI's semicolon blobs, so
the next Sophie update produces a diff you can read. Exclusions live in
zomboid_mods_excluded with a reason each, seeded with IconsInventory -- already
absent upstream, listed so a re-vendor cannot quietly bring it back.

Config is now seeded before first boot instead of patched after it. The old
approach could not write the INI until the server had generated one, so every
setting went through lineinfile guarded on a stat; seeding the whole file from
the preset removes the chicken-and-egg and puts the config in git. force is
off by design: these files are a starting point, not managed state, so the
server can be stopped, hand-edited and regenerated without Ansible clobbering
the edits. Push them again deliberately with -e zomboid_config_force=true.

The B41 Discord keys were dead. B42 replaced DiscordChannel/DiscordChannelID
with DiscordChatChannel, which takes a channel name rather than a snowflake,
so the DiscordChannelID=... this role had been appending was a key the server
ignores and the chat bridge has not been working.

Renaming the server to debbzoid is what starts the new world: PZ derives the
INI, save directory and player DB from the name, so gregboid stays on disk
untouched as a rollback.

Re-enabled, because the reason it was off is now fixed. It was disabled for
saturating the SSD -- ~10 MB/s of log writes into the shared journal, which
starved Gitea CI badly enough to stretch a firmware build to 17 minutes. The
container now logs to its own rotating k8s-file instead of the journal every
other service shares, and logrotate caps the server's own server-console.txt
and Logs/ at 100M keeping 3. copytruncate is mandatory there: PZ holds those
fds for its whole life, so a rename would leave it writing into an unlinked
inode. Memory is unchanged; idle draw is nowhere near MAX_RAM.

Also stops the entrypoint recursively chowning the install tree on every boot.
With 256 workshop mods that is a large inode walk and pure churn once the
first run has set ownership.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:41:00 -04:00
Bastian de BylandClaude Opus 5 eea55def6c retire Graylog behind a flag, fix Caddy reloads, reap awsddns zombies
Graylog was the worst cost/benefit tenant on this 4-core box: two JVMs plus
MongoDB holding ~1.6 GB resident and ~3% CPU around the clock to store ~3k
messages a day -- about 28 MB across its four live indices. journald already
retains ~25 days of the same logs at its 500M cap, so this costs searchability,
not the logs.

The switch is `graylog_enabled` in inventory rather than a role default,
because three roles read it (common, podman, graylog-config). The disabled
path is an active teardown, not a skipped create: the containers already on
the host keep running and their systemd user units keep restarting them at
boot unless something stops and removes them. fluent-bit follows the same
flag -- with the GELF sink down it would spin retrying a dead 127.0.0.1:12202
and fill the journal it exists to drain -- but only the service state follows,
so re-enabling is a restart rather than a reinstall.

Caddy reloads were silently no-ops. The handler read /etc/caddy/Caddyfile,
which is a single-file bind mount, and podman binds those by inode; the
template module writes a temp file and renames it into place, so every deploy
gave the host file a new inode while the container kept seeing the one it was
created with. Config changes only ever landed when something recreated the
container. {{ caddy_path }}/config is also mounted, as a *directory*, and
directory mounts resolve names at open() time -- so /config/Caddyfile is
always the file Ansible just wrote.

awsddns and its four siblings had accumulated 12 zombies over 30 days of
uptime. The image's PID 1 is busybox crond, which only waitpid()s the job PIDs
it tracks and does no generic orphan reaping, so whenever the run-parts/sh
layer exited before the script it left a permanent <defunct>. init: true puts
catatonit at PID 1 to reap them, and the recreation clears the existing ones.

Also bumps fulfillr and greg-time-bot images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:40:10 -04:00
27 changed files with 4027 additions and 211 deletions
+4
View File
@@ -9,7 +9,11 @@
# SSL certificates are now handled automatically by Caddy
# - role: ssl # REMOVED - Caddy handles all certificate management
- role: github-actions
# Provisions streams/pipelines/lookup tables over the Graylog REST API, so
# it can only run when the stack is up -- see graylog_enabled in
# inventories/home/hosts.yml.
- role: graylog-config
when: graylog_enabled | bool
tags: graylog-config
- role: ups
tags: ups
+21
View File
@@ -1,5 +1,26 @@
---
all:
vars:
# Master switch for the Graylog logging stack (graylog + graylog-opensearch
# + graylog-mongo), the fluent-bit journal shipper that feeds it, the GeoIP
# database download it enriches with, and the graylog-config role that
# provisions its streams/pipelines over the REST API.
#
# Lives in inventory rather than a role default because it is read by three
# separate roles (common, podman, graylog-config) and role defaults are
# scoped to their own role.
#
# Off because the stack is the worst cost/benefit tenant on this 4-core box:
# two JVMs plus MongoDB hold ~1.6 GB resident and burn ~3% of the CPU around
# the clock to store ~3k messages a day -- about 28 MB of actual log data
# across its four live indices. journald already retains ~25 days of the
# same logs at its 500M cap, so turning this off costs searchability, not
# the logs themselves.
#
# Set true (here, or -e graylog_enabled=true) to bring it back. All data
# under {{ graylog_path }} is left in place, so re-enabling resumes with the
# existing indices, streams and pipelines intact.
graylog_enabled: false
hosts:
home.debyl.io:
ansible_user: fedora
+4
View File
@@ -11,11 +11,15 @@
name: fail2ban
state: restarted
# Guarded: a config change still notifies this handler when graylog_enabled is
# false, and an unconditional restart would start the service the fluent-bit
# task just stopped.
- name: restart fluent-bit
become: true
ansible.builtin.systemd:
name: fluent-bit
state: restarted
when: graylog_enabled | bool
- name: restart_journald
become: true
+9 -3
View File
@@ -1,6 +1,12 @@
---
# Fluent Bit - Log forwarder from journald to Graylog GELF
# Deployed as systemd service (not container) for direct journal access
#
# Graylog's GELF input is fluent-bit's only output, so the two share a switch:
# with the stack down fluent-bit would just spin retrying a dead 127.0.0.1:12202
# and filling the journal it is meant to be draining. The package and config
# stay installed either way -- only the service follows graylog_enabled (see
# inventories/home/hosts.yml) -- so re-enabling is a restart, not a reinstall.
- name: install fluent-bit package
become: true
@@ -37,9 +43,9 @@
mode: '0644'
notify: restart fluent-bit
- name: enable and start fluent-bit service
- name: set fluent-bit service state to match graylog_enabled
become: true
ansible.builtin.systemd:
name: fluent-bit
enabled: true
state: started
enabled: "{{ graylog_enabled | bool }}"
state: "{{ 'started' if (graylog_enabled | bool) else 'stopped' }}"
@@ -3,10 +3,20 @@
# 1.9 TB root means it will happily grow into the tens of gigabytes; it had
# reached 4 GB before this was set.
#
# Kept small on purpose: every container runs with log_driver=journald and
# Container stdout lands here: every container runs with log_driver=journald
# except zomboid, which is chatty enough to evict everything else and so gets
# its own rotating k8s-file log instead.
{% if graylog_enabled | bool %}
# fluent-bit drains the journal into Graylog continuously (systemd input,
# _COMM=conmon -- see templates/fluent-bit/fluent-bit.conf.j2), so Graylog is
# the system of record. What stays here is only the buffer that covers
# fluent-bit being down, and 500M is a long outage at this log rate.
# the system of record and what stays here is only the buffer that covers
# fluent-bit being down.
{% else %}
# Graylog is disabled (see graylog_enabled in inventories/home/hosts.yml), so
# the journal is now the only log store. No increase was needed for that: the
# cap is a size limit, not a time limit, and fluent-bit only ever read the
# journal rather than rotating it, so retention is unchanged at roughly 25 days
# at the current rate -- longer than Graylog's own four live indices covered.
{% endif %}
[Journal]
SystemMaxUse={{ journald_max_use | default('500M') }}
+61 -4
View File
@@ -19,10 +19,15 @@ Stages, in order — the ordering is deliberate, see the comments in the templat
2. **SQLite snapshots** (`.backup`, then `pragma integrity_check`) where used.
3. **rsync** of the data tree, config, and db dumps to TrueNAS.
Failures raise `status=failed` on the `nextcloud-backup` syslog tag, which an
external Graylog rule matches to send mail. Do not rename that tag: it is shared
by every instance including Gitea, and renaming it here silently stops alerting
for all of them.
Failures raise `status=failed` on the `nextcloud-backup` syslog tag. The mail
itself is sent by the unit's own `OnFailure=` handler
(`templates/nextcloud/nextcloud-backup-alert.sh.j2`) straight through
`sendmail`, so alerting does **not** depend on Graylog and is unaffected by
`graylog_enabled` being off. A Graylog rule matching the same tag is a
secondary, dashboard-side copy of that signal.
Do not rename that tag: it is shared by every instance including Gitea, and
renaming it here silently stops the Graylog-side alerting for all of them.
## Restore
@@ -141,3 +146,55 @@ LibreSign setup failures and buys nothing at this scale.
- `LC_ALL` / `LANG` must be set or the JVM comes up as `ANSI_X3.4-1968` and
LibreSign warns that accented characters in signer names will be mangled
(LibreSign issue #4872).
## Logging
Every container runs with `log_driver=journald`, so container stdout lands in
the host journal, capped at 500M by `roles/common/templates/journald-size.conf.j2`
(about 25 days at the current rate). `journalctl CONTAINER_NAME=<name>` is the
day-to-day way to read it.
On top of that sits an optional Graylog stack — `graylog`, `graylog-opensearch`,
`graylog-mongo`, fed by a host `fluent-bit` service that tails the journal and
ships GELF to `127.0.0.1:12202`, enriched with the MaxMind GeoIP database, and
configured over the REST API by the separate `graylog-config` role.
**It is off.** The switch is `graylog_enabled` in
`inventories/home/hosts.yml`, and it gates all five of those pieces at once:
| `graylog_enabled` | effect |
| --- | --- |
| `true` | stack deployed, fluent-bit shipping, GeoIP downloaded, `graylog-config` runs, `logs.debyl.io` proxies to the UI and `/gelf` |
| `false` | containers removed and their systemd user units disabled and deleted, fluent-bit stopped and disabled, GeoIP and `graylog-config` skipped, `logs.debyl.io` answers 503 |
It was turned off because the cost/benefit is bad on a 4-core box: two JVMs plus
MongoDB held ~1.6 GB resident and ~3% of the CPU continuously to store roughly
3k messages a day — about 28 MB of real log data across four live indices — all
of which journald already keeps for longer.
Turning it back on is `graylog_enabled: true` plus `make deploy TAGS=graylog`.
Nothing is destroyed by the off path: the volumes under `{{ graylog_path }}`
keep the indices, the Mongo database holding streams/pipelines/dashboards, and
the node-id file, so the stack comes back with its configuration intact.
Two things genuinely stop while it is off, both by design:
- **Search and dashboards.** The logs still exist in journald; the query
interface over them does not.
- **External GELF ingest.** The AWS Lambda that POSTs to `logs.debyl.io/gelf`
for the `debyltech-api` stream gets a 503. Those events are dropped, not
queued — nothing else records them.
### Caddy config reloads
The `reload caddy` handler deliberately reads `/config/Caddyfile`, not the
`/etc/caddy/Caddyfile` the container starts from, even though both are the same
host file. `/etc/caddy/Caddyfile` is a **single-file** bind mount, which podman
binds by inode, and Ansible's `template` module writes a temp file and renames
it into place — so every deploy gives the host file a new inode while the
container keeps seeing the one it was created with. Reloading from that path
silently re-applied the previous config; changes only landed when something
recreated the container. `{{ caddy_path }}/config` is also bind-mounted as a
*directory* at `/config`, and directory mounts resolve names at `open()` time,
so `/config/Caddyfile` is always the file Ansible just wrote.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
function SpawnPoints()
return {
unemployed = {
{ worldX = 40, worldY = 22, posX = 67, posY = 201 }
}
}
end
@@ -0,0 +1,15 @@
function SpawnRegions()
return {
{ name = "Brandenburg, KY", file = "media/maps/Brandenburg, KY/spawnpoints.lua" },
{ name = "Echo Creek, KY", file = "media/maps/Echo Creek, KY/spawnpoints.lua" },
{ name = "Ekron, KY", file = "media/maps/Ekron, KY/spawnpoints.lua" },
{ name = "Fallas Lake, KY", file = "media/maps/Fallas Lake, KY/spawnpoints.lua" },
{ name = "Irvington, KY", file = "media/maps/Irvington, KY/spawnpoints.lua" },
{ name = "March Ridge, KY", file = "media/maps/March Ridge, KY/spawnpoints.lua" },
{ name = "Muldraugh, KY", file = "media/maps/Muldraugh, KY/spawnpoints.lua" },
{ name = "Riverside, KY", file = "media/maps/Riverside, KY/spawnpoints.lua" },
{ name = "Rosewood, KY", file = "media/maps/Rosewood, KY/spawnpoints.lua" },
{ name = "Valley Station, KY", file = "media/maps/Valley Station, KY/spawnpoints.lua" },
{ name = "West Point, KY", file = "media/maps/West Point, KY/spawnpoints.lua" },
}
end
+20 -1
View File
@@ -25,11 +25,21 @@
tags:
- caddy
# Reads /config/Caddyfile, NOT the /etc/caddy/Caddyfile the container starts
# from, even though both are the same host file. /etc/caddy/Caddyfile is a
# single-file bind mount, which podman binds by inode; the template module
# writes a temp file and renames it into place, so every deploy gives the host
# file a new inode and the container keeps seeing the one it was created with.
# Reloading from that path silently re-applied the old config -- the change only
# ever landed when something recreated the container. {{ caddy_path }}/config is
# also bind-mounted as a *directory* at /config, and a directory mount resolves
# names at open() time, so /config/Caddyfile is always the file Ansible just
# wrote.
- name: reload caddy
become: true
become_user: "{{ podman_user }}"
ansible.builtin.command: |
podman exec caddy caddy reload --config /etc/caddy/Caddyfile
podman exec caddy caddy reload --config /config/Caddyfile --adapter caddyfile
tags:
- caddy
- caddy-config
@@ -42,3 +52,12 @@
scope: user
tags:
- zomboid
- name: reload podman systemd
become: true
become_user: "{{ podman_user }}"
ansible.builtin.systemd:
daemon_reload: true
scope: user
tags:
- podman
@@ -1,4 +1,14 @@
---
# The image's PID 1 is busybox crond (ENTRYPOINT crond -f -d 8), which runs the
# /etc/periodic/15min/awsddns script every quarter hour. busybox crond only
# waitpid()s the job PIDs it is tracking -- it does no generic orphan reaping --
# so whenever the run-parts/sh layer between crond and the script exits first,
# the script is reparented to PID 1 and stays <defunct> forever. That had left
# 12 zombies across these five containers after 30 days of uptime.
#
# init: true makes podman inject catatonit as PID 1 with crond as its child, and
# catatonit reaps every orphan it inherits. Changing this recreates the
# containers, which also clears the zombies already accumulated.
- import_tasks: podman/podman-check.yml
vars:
container_name: awsddns
@@ -13,6 +23,7 @@
image: "{{ image }}"
restart_policy: on-failure:3
log_driver: journald
init: true
env:
AWS_ZONE_TTL: 60
AWS_ZONE_ID: "{{ aws_zone_id }}"
@@ -40,6 +51,7 @@
image: "{{ image }}"
restart_policy: on-failure:3
log_driver: journald
init: true
env:
AWS_ZONE_TTL: 60
AWS_ZONE_ID: "{{ aws_skudak_zone_id }}"
@@ -67,6 +79,7 @@
image: "{{ image }}"
restart_policy: on-failure:3
log_driver: journald
init: true
env:
AWS_ZONE_TTL: 60
AWS_ZONE_ID: "{{ fulfillr_zone_id }}"
@@ -96,6 +109,7 @@
image: "{{ image }}"
restart_policy: on-failure:3
log_driver: journald
init: true
env:
AWS_ZONE_TTL: 60
AWS_ZONE_ID: "{{ fulfillr_zone_id }}"
@@ -123,6 +137,7 @@
image: "{{ image }}"
restart_policy: on-failure:3
log_driver: journald
init: true
env:
AWS_ZONE_TTL: 60
AWS_ZONE_ID: "Z07501202A6AYMHCVP50A"
@@ -0,0 +1,59 @@
---
# Runs in place of graylog.yml when graylog_enabled is false.
#
# A bare `when:` on the import would only stop Ansible from *creating* the
# stack; containers already on the host would keep running and their systemd
# user units would keep starting them at boot. So the disabled path has to be an
# active teardown: disable the units, then remove the containers.
#
# Volumes under {{ graylog_path }} are deliberately untouched -- indices, the
# Mongo database holding streams/pipelines/dashboards, and the node-id file all
# survive, so flipping graylog_enabled back to true resumes where this left off.
# graylog is stopped first: its unit `requires` the other two, and tearing down
# a dependency out from under it makes the shutdown noisy for no reason.
- name: stop and disable graylog stack systemd units
become: true
become_user: "{{ podman_user }}"
ansible.builtin.systemd:
name: "{{ item }}.service"
enabled: false
state: stopped
daemon_reload: true
scope: user
loop:
- graylog
- graylog-opensearch
- graylog-mongo
# Left over from the Elasticsearch-to-OpenSearch migration; the container is
# long gone but the enabled unit still tries to start it every boot.
- graylog-elastic
register: graylog_units
failed_when: false
tags: graylog
- name: remove graylog stack containers
become: true
become_user: "{{ podman_user }}"
containers.podman.podman_container:
name: "{{ item }}"
state: absent
loop:
- graylog
- graylog-opensearch
- graylog-mongo
tags: graylog
- name: remove graylog stack systemd unit files
become: true
become_user: "{{ podman_user }}"
ansible.builtin.file:
path: "{{ podman_home }}/.config/systemd/user/{{ item }}.service"
state: absent
loop:
- graylog
- graylog-opensearch
- graylog-mongo
- graylog-elastic
notify: reload podman systemd
tags: graylog
@@ -1,4 +1,9 @@
---
- name: load vendored sophie modlist
ansible.builtin.include_vars:
file: zomboid_sophie_mods.yml
tags: zomboid-conf
- name: create zomboid host directory volumes
become: true
ansible.builtin.file:
@@ -122,6 +127,97 @@
mode: 0644
notify: restorecon podman
# Server config is seeded from the vendored Sophie 42 preset *before* first
# boot, so the server never generates a vanilla INI we then have to patch.
#
# force is off by design: these files are a starting point, not a managed
# state. Stop the server, hand-edit them, regenerate the world -- Ansible will
# not clobber the edits on the next deploy. Re-push deliberately with
# -e zomboid_config_force=true.
- name: create zomboid server config directory
become: true
ansible.builtin.file:
path: "{{ zomboid_path }}/data/Server"
state: directory
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_user }}"
mode: 0755
notify: restorecon podman
tags: zomboid-conf
- name: seed zomboid server ini from sophie preset
become: true
ansible.builtin.template:
src: zomboid/server.ini.j2
dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}.ini"
force: "{{ zomboid_config_force | bool }}"
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_user }}"
mode: 0644
notify: restorecon podman
tags: zomboid-conf
# Copied, not templated: these are Lua and must not go through Jinja.
- name: seed zomboid sandbox and spawn config from sophie preset
become: true
ansible.builtin.copy:
src: "zomboid/sophie/{{ item }}.lua"
dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}_{{ item }}.lua"
force: "{{ zomboid_config_force | bool }}"
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_user }}"
mode: 0644
loop:
- SandboxVars
- spawnregions
- spawnpoints
notify: restorecon podman
tags: zomboid-conf
- name: deploy zomboid log prune script
become: true
ansible.builtin.template:
src: zomboid/zomboid-log-prune.sh.j2
dest: "{{ podman_home }}/bin/zomboid-log-prune.sh"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
- name: deploy zomboid log prune units
become: true
ansible.builtin.template:
src: "zomboid/zomboid-log-prune.{{ item }}.j2"
dest: "{{ podman_home }}/.config/systemd/user/zomboid-log-prune.{{ item }}"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
loop:
- service
- timer
notify: reload zomboid systemd
- name: enable zomboid log prune timer
become: true
become_user: "{{ podman_user }}"
ansible.builtin.systemd:
name: zomboid-log-prune.timer
scope: user
enabled: true
state: started
daemon_reload: true
# The server's own logs, not the container's. PZ holds these fds open for the
# life of the process, so copytruncate is mandatory -- a rename would leave it
# writing into an unlinked inode and the file would appear to stop growing.
- name: deploy zomboid logrotate config
become: true
ansible.builtin.template:
src: zomboid/logrotate.j2
dest: /etc/logrotate.d/zomboid
owner: root
group: root
mode: 0644
# Set volume permissions for steam user (UID 1000) inside container
# This uses podman unshare to set ownership correctly for rootless podman
- name: set zomboid volume permissions for steam user
@@ -147,11 +243,13 @@
name: zomboid
image: "{{ image }}"
restart_policy: on-failure:3
log_driver: journald
log_driver: k8s-file
log_opt:
max_size: 50m
env:
SERVER_NAME: "{{ zomboid_server_names[zomboid_server_mode] }}"
MIN_RAM: 8g
MAX_RAM: 24g
SERVER_NAME: "{{ zomboid_server_name }}"
MIN_RAM: "{{ zomboid_min_ram }}"
MAX_RAM: "{{ zomboid_max_ram }}"
AUTO_UPDATE: "true"
ADMIN_PASSWORD: "{{ zomboid_admin_password }}"
SERVER_PASSWORD: "{{ zomboid_password }}"
@@ -173,112 +271,43 @@
vars:
container_name: zomboid
# Ensure zomboid restarts on any exit (including admin-triggered restarts)
- name: configure zomboid systemd to always restart
# Make systemd actually supervise the container, so it restarts on any exit --
# including the clean one that `@bot restart` produces via RCON quit.
#
# `podman generate systemd` emits Type=forking with ExecStart=podman start and
# a PIDFile pointing at conmon. podman start returns immediately, so the process
# systemd is told to watch was never its child; it says so itself in the journal
# ("Supervising process N which is not our child. We'll most likely not notice
# when it exits") and it does not notice. The unit sat at active/running with
# NRestarts=0 while the container was exited(0) and the server was down. The
# PIDFile is worse than useless here: it embeds the container ID, so it goes
# stale every time a deploy recreates the container.
#
# Type=simple with `podman start -a` keeps podman in the foreground as systemd's
# own child, so the exit is seen and Restart=always fires.
#
# stdout/stderr are discarded on purpose. Attaching re-emits the container's
# output on podman's stdout, which systemd would capture straight back into the
# journal -- exactly the flood the k8s-file log driver exists to avoid. Nothing
# is lost: `podman logs` still serves it from the container's own log.
- name: configure zomboid systemd supervision
become: true
become_user: "{{ podman_user }}"
ansible.builtin.lineinfile:
path: "{{ podman_home }}/.config/systemd/user/zomboid.service"
regexp: "^Restart="
line: "Restart=always"
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
state: "{{ item.state | default('present') }}"
insertafter: '^\[Service\]'
loop:
- { regexp: '^Restart=', line: 'Restart=always' }
- { regexp: '^Type=', line: 'Type=simple' }
- { regexp: '^ExecStart=', line: 'ExecStart=/usr/bin/podman start -a zomboid' }
- { regexp: '^StandardOutput=', line: 'StandardOutput=null' }
- { regexp: '^StandardError=', line: 'StandardError=null' }
- { regexp: '^PIDFile=', line: '', state: absent }
notify: reload zomboid systemd
# Check if server INI exists (generated on first server run)
- name: check if zomboid server ini exists
become: true
ansible.builtin.stat:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
register: zomboid_ini_stat
tags: zomboid-conf
# Backup settings (requires server to have run once to generate ini)
- name: configure zomboid backup settings
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "SaveWorldEveryMinutes", value: "10" }
- { key: "BackupsPeriod", value: "30" }
- { key: "BackupsCount", value: "10" }
# B42 Linux server fix: disable Lua checksum to allow mods to load
- { key: "DoLuaChecksum", value: "false" }
# Server password
- { key: "Password", value: "{{ zomboid_password }}" }
when: zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Discord integration (uses Gregbot token, posts /all chat to Discord)
- name: configure zomboid discord integration
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "DiscordEnable", value: "true" }
- { key: "DiscordToken", value: "{{ zomboid_discord_token }}" }
- { key: "DiscordChannel", value: "zomboidbot" }
- { key: "DiscordChannelID", value: "1451961291194241095" }
when: zomboid_ini_stat.stat.exists
tags: zomboid-conf
# RCON configuration for remote administration
- name: configure zomboid rcon
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "RCONPort", value: "{{ zomboid_rcon_port }}" }
- { key: "RCONPassword", value: "{{ zomboid_admin_password }}" }
when: zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Mod configuration for 'modded' server profile
- name: configure zomboid mods for modded server
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "Mods", value: "{{ zomboid_mods.mod_ids }}" }
- { key: "WorkshopItems", value: "{{ zomboid_mods.workshop_items }}" }
when:
- zomboid_server_mode == 'modded'
- zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Mod configuration for 'b42revamp' server profile (168 mods from Steam collection)
- name: configure zomboid mods for b42revamp server
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "Mods", value: "{{ zomboid_mods_b42revamp.mod_ids }}" }
- { key: "WorkshopItems", value: "{{ zomboid_mods_b42revamp.workshop_items }}" }
when:
- zomboid_server_mode == 'b42revamp'
- zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Map configuration (for modded servers with custom maps)
- name: configure zomboid map
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^Map="
line: "Map={{ zomboid_maps[zomboid_server_mode] }}"
when:
- zomboid_server_mode != 'vanilla'
- zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Firewall logging for player IP correlation
# Logs new UDP connections to Zomboid port for IP address tracking
- name: add firewall rule to log zomboid connections
@@ -436,71 +465,198 @@
notify: restart firewalld
tags: firewall
# Priority 4: LOG rate-limited queries from unverified IPs
# Very aggressive: 2 burst, then 1 per hour
# Note: Uses same hashlimit name as DROP rule to share bucket
- name: log rate-limited queries from unverified IPs on 16261
# Priority 4/5: query flood limiting.
#
# The original thresholds were 1/hour burst 2 per source IP, on the theory that
# only scanners send 53-byte query packets and that real players would first be
# marked verified by the priority-2 rule when they sent something else. That
# premise is inverted: a client's *first* contact is a 53-byte query, so nobody
# can be verified before they query, and nobody can query more than twice an
# hour without being dropped. The counters were unambiguous -- 5 packets ever
# matched the verified-accept rule against 30,563 drops -- and fail2ban then
# banned the dropped players for a week each, several an hour, all residential
# IPs. The server was unreachable for everyone.
#
# Remove the old rules so hosts carrying them converge, then re-add the same
# shape at a threshold no real client reaches. Opening the server browser sends
# a handful of queries; a flood sends thousands.
- name: remove broken log query rate-limit rule on 16261 (permanent)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 4
firewall-cmd --permanent --direct --remove-rule ipv4 filter INPUT 4
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
register: unverified_log_result
changed_when: "'already' not in unverified_log_result.stderr"
changed_when: false
failed_when: false
notify: restart firewalld
tags: firewall
- name: log rate-limited queries from unverified IPs on 16262
- name: remove broken log query rate-limit rule on 16261 (runtime)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 4
-p udp --dport 16262 -m conntrack --ctstate NEW
firewall-cmd --direct --remove-rule ipv4 filter INPUT 4
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
register: unverified_log_result_16262
changed_when: "'already' not in unverified_log_result_16262.stderr"
changed_when: false
failed_when: false
notify: restart firewalld
tags: firewall
# Priority 5: DROP rate-limited queries from unverified IPs
# Note: Uses same hashlimit name as LOG rule to share bucket
- name: drop rate-limited queries from unverified IPs on 16261
- name: remove broken drop query rate-limit rule on 16261 (permanent)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 5
firewall-cmd --permanent --direct --remove-rule ipv4 filter INPUT 5
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j DROP
register: unverified_drop_result
changed_when: "'already' not in unverified_drop_result.stderr"
changed_when: false
failed_when: false
notify: restart firewalld
tags: firewall
- name: drop rate-limited queries from unverified IPs on 16262
- name: remove broken drop query rate-limit rule on 16261 (runtime)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 5
firewall-cmd --direct --remove-rule ipv4 filter INPUT 5
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j DROP
changed_when: false
failed_when: false
tags: firewall
- name: remove broken log query rate-limit rule on 16262 (permanent)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --remove-rule ipv4 filter INPUT 4
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
changed_when: false
failed_when: false
tags: firewall
- name: remove broken log query rate-limit rule on 16262 (runtime)
become: true
ansible.builtin.command: >
firewall-cmd --direct --remove-rule ipv4 filter INPUT 4
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
changed_when: false
failed_when: false
tags: firewall
- name: remove broken drop query rate-limit rule on 16262 (permanent)
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --remove-rule ipv4 filter INPUT 5
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j DROP
register: unverified_drop_result_16262
changed_when: "'already' not in unverified_drop_result_16262.stderr"
changed_when: false
failed_when: false
tags: firewall
- name: remove broken drop query rate-limit rule on 16262 (runtime)
become: true
ansible.builtin.command: >
firewall-cmd --direct --remove-rule ipv4 filter INPUT 5
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above 1/hour --hashlimit-burst 2
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j DROP
changed_when: false
failed_when: false
tags: firewall
- name: log query floods on 16261
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 4
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above {{ zomboid_query_rate_limit }}
--hashlimit-burst {{ zomboid_query_burst }}
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
register: qflood_log_16261
changed_when: "'already' not in qflood_log_16261.stderr"
failed_when: false
notify: restart firewalld
tags: firewall
- name: drop query floods on 16261
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 5
-p udp --dport 16261 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above {{ zomboid_query_rate_limit }}
--hashlimit-burst {{ zomboid_query_burst }}
--hashlimit-mode srcip --hashlimit-name zomboid_query_16261
--hashlimit-htable-expire 3600000
-j DROP
register: qflood_drop_16261
changed_when: "'already' not in qflood_drop_16261.stderr"
failed_when: false
notify: restart firewalld
tags: firewall
- name: log query floods on 16262
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 4
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above {{ zomboid_query_rate_limit }}
--hashlimit-burst {{ zomboid_query_burst }}
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j LOG --log-prefix "ZOMBOID_RATELIMIT: " --log-level 4
register: qflood_log_16262
changed_when: "'already' not in qflood_log_16262.stderr"
failed_when: false
notify: restart firewalld
tags: firewall
- name: drop query floods on 16262
become: true
ansible.builtin.command: >
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 5
-p udp --dport 16262 -m conntrack --ctstate NEW
-m length --length 53
-m hashlimit --hashlimit-above {{ zomboid_query_rate_limit }}
--hashlimit-burst {{ zomboid_query_burst }}
--hashlimit-mode srcip --hashlimit-name zomboid_query_16262
--hashlimit-htable-expire 3600000
-j DROP
register: qflood_drop_16262
changed_when: "'already' not in qflood_drop_16262.stderr"
failed_when: false
notify: restart firewalld
tags: firewall
+22 -8
View File
@@ -55,39 +55,39 @@
- import_tasks: containers/skudak/wiki.yml
vars:
db_image: docker.io/library/mysql:5.7.21
image: docker.io/solidnerd/bookstack:26.3.4
image: docker.io/solidnerd/bookstack:26.5.4
tags: skudak, skudak-wiki
- import_tasks: containers/home/photos.yml
vars:
db_image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
ml_image: ghcr.io/immich-app/immich-machine-learning:v3.0.0
ml_image: ghcr.io/immich-app/immich-machine-learning:v3.1.0
redis_image: docker.io/redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
image: ghcr.io/immich-app/immich-server:v3.0.0
image: ghcr.io/immich-app/immich-server:v3.1.0
tags: photos
- import_tasks: containers/home/cloud.yml
vars:
db_image: docker.io/library/mariadb:10.6
image: docker.io/library/nextcloud:34.0.2-apache
image: docker.io/library/nextcloud:34.0.3-apache
tags: cloud
- import_tasks: containers/skudak/cloud.yml
vars:
db_image: docker.io/library/mariadb:10.6
redis_image: docker.io/redis:8.2-alpine
image: docker.io/library/nextcloud:34.0.2-apache
image: docker.io/library/nextcloud:34.0.3-apache
tags: skudak, skudak-cloud
- import_tasks: containers/debyltech/fulfillr.yml
vars:
image: git.debyl.io/debyltech/fulfillr:20260728.2155
image: git.debyl.io/debyltech/fulfillr:20260825.1909
tags: debyltech, fulfillr
# Staging back-office (fulfillr-dev.debyltech.com) — same image, staging Turso config.
- import_tasks: containers/debyltech/fulfillr-dev.yml
vars:
image: git.debyl.io/debyltech/fulfillr:20260728.2155
image: git.debyl.io/debyltech/fulfillr:20260825.1909
tags: debyltech, fulfillr-dev
- import_tasks: containers/debyltech/uptime-kuma.yml
@@ -100,7 +100,10 @@
image: docker.io/louislam/uptime-kuma:2.3.2
tags: home, uptime
# GeoIP is only consumed by Graylog's enrichment pipelines, so it follows the
# same switch -- see graylog_enabled in inventories/home/hosts.yml.
- import_tasks: data/geoip.yml
when: graylog_enabled | bool
tags: graylog, geoip
- import_tasks: containers/debyltech/graylog.yml
@@ -108,16 +111,27 @@
mongo_image: docker.io/mongo:7.0
opensearch_image: docker.io/opensearchproject/opensearch:2
image: docker.io/graylog/graylog:7.0.1
when: graylog_enabled | bool
tags: debyltech, graylog
# The disabled path is an active teardown, not just a skipped create: without it
# the containers already on the host keep running and their systemd user units
# keep restarting them at boot.
- import_tasks: containers/debyltech/graylog-teardown.yml
when: not (graylog_enabled | bool)
tags: debyltech, graylog
- import_tasks: containers/home/gregtime.yml
vars:
image: localhost/greg-time-bot:3.10.0
image: localhost/greg-time-bot:3.16.1
tags: gregtime
# Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken
# down for a CI-heavy stretch without losing the world.
- import_tasks: containers/home/zomboid.yml
vars:
image: docker.io/cm2network/steamcmd:root
when: zomboid_enabled | bool
tags: zomboid
# ---------------------------------------------------------- Gitea backups
@@ -238,6 +238,34 @@
}
# Graylog Logs - {{ logs_server_name }}
{% if not (graylog_enabled | bool) %}
# Graylog is disabled (see graylog_enabled in inventories/home/hosts.yml). The
# vhost is kept rather than dropped so the name keeps resolving and its
# certificate keeps renewing; both handlers answer 503 instead of proxying to a
# port nothing is listening on, which would hang and then 502.
#
# NOTE: this means the external Lambda that POSTs to /gelf for the
# `debyltech-api` stream is being turned away. Its events are dropped, not
# queued -- nothing else records them.
{{ logs_server_name }} {
handle /gelf {
respond "Log ingest disabled" 503
}
handle {
respond "Graylog is disabled" 503
}
log {
output file /var/log/caddy/graylog.log {
roll_size {{ caddy_log_roll_size }}
roll_keep {{ caddy_log_roll_keep }}
roll_keep_for {{ caddy_log_roll_keep_for }}
}
format json
}
}
{% else %}
{{ logs_server_name }} {
# GELF HTTP endpoint - open for Lambda (auth via header)
# Must come BEFORE ip_restricted_site to allow external access
@@ -283,6 +311,7 @@
format json
}
}
{% endif %}
# ============================================================================
# COMPLEX CONFIGURATIONS
@@ -10,10 +10,10 @@ STEAMCMD="/home/steam/steamcmd/steamcmd.sh"
SERVER_NAME="${SERVER_NAME:-zomboid}"
PUID="${PUID:-1000}"
PGID="${PGID:-1000}"
MIN_RAM="${MIN_RAM:-4g}"
MAX_RAM="${MAX_RAM:-8g}"
MIN_RAM="${MIN_RAM:-{{ zomboid_min_ram }}}"
MAX_RAM="${MAX_RAM:-{{ zomboid_max_ram }}}"
echo "=== Project Zomboid Build 42 Server ==="
echo "=== Project Zomboid Build 42 Server ({{ zomboid_preset_version }}) ==="
echo "Server Name: ${SERVER_NAME}"
echo "RAM: ${MIN_RAM} - ${MAX_RAM}"
@@ -21,7 +21,15 @@ echo "RAM: ${MIN_RAM} - ${MAX_RAM}"
echo "=== Setting file permissions ==="
usermod -o -u "${PUID}" steam
groupmod -o -g "${PGID}" steam
chown -R steam:steam "${INSTALL_DIR}" "${CONFIG_DIR}"
for dir in "${INSTALL_DIR}" "${CONFIG_DIR}"; do
# Recursing is a full inode walk over the install tree -- with 256 workshop
# mods that is real SSD churn on every restart, and a no-op after the first.
if [ "$(stat -c %u "${dir}")" = "${PUID}" ]; then
echo "=== ${dir} already owned by ${PUID}, skipping recursive chown ==="
else
chown -R steam:steam "${dir}"
fi
done
# Only chown writable parts of /home/steam (not read-only mounts)
chown steam:steam /home/steam
chown -R steam:steam /home/steam/steamcmd 2>/dev/null || true
@@ -32,13 +40,40 @@ mkdir -p "${CONFIG_DIR}/Server"
mkdir -p "${CONFIG_DIR}/Saves"
mkdir -p "${CONFIG_DIR}/db"
# Update/Install PZ dedicated server with Build 42 unstable branch
# Update/Install PZ dedicated server from the default (stable) branch
if [ "${AUTO_UPDATE:-true}" = "true" ]; then
echo "=== Updating Project Zomboid Server (Build 42 unstable) ==="
echo "=== Updating Project Zomboid Server (Build 42 stable) ==="
su -c "${STEAMCMD} +runscript /home/steam/install.scmd" steam
echo "=== Update complete ==="
fi
# Strip scripts the dedicated-server package ships but the client no longer has.
#
# The two Steam apps are packaged separately and have drifted. Base.Log_Stack_01
# was removed from the client in 42.16, but app 380870 still ships
# entity_logstack.txt. The server registers every script it can see into a new
# world's WorldDictionary, so the entry lands in the save, and then every client
# fails at world load with:
#
# WorldDictionaryException: [SpriteConfigs] Missing dictionary script on
# client: Base.Log_Stack_01
#
# Nothing in multiplayer can legitimately use a script no client has, so
# removing it server-side costs nothing. This runs after the SteamCMD update on
# purpose: validate restores the file on every boot, so it has to be stripped
# each time, not once.
#
# Removing the file does not fix a world whose dictionary already records the
# entry -- that save needs regenerating.
strip_client_missing_scripts() {
local stale="${INSTALL_DIR}/media/scripts/generated/entities/misc/entity_logstack.txt"
if [ -f "${stale}" ]; then
echo "=== Removing ${stale##*/} (shipped by server, absent from client) ==="
rm -f "${stale}"
fi
}
strip_client_missing_scripts
# Configure JVM memory settings in ProjectZomboid64.json (Build 42 uses JSON config)
configure_memory() {
local json_file="${INSTALL_DIR}/ProjectZomboid64.json"
@@ -65,17 +100,22 @@ configure_memory() {
}
configure_memory
# Check if first run (no admin DB)
ADMIN_DB="${CONFIG_DIR}/db/${SERVER_NAME}.db"
# Build server arguments
# Note: -modfolders is NOT used - mods are configured via INI only
# Reference: IndifferentBroccoli/projectzomboid-server-docker
SERVER_ARGS="-cachedir=${CONFIG_DIR} -servername ${SERVER_NAME}"
# Add admin password for first run
if [ ! -f "${ADMIN_DB}" ] && [ -n "${ADMIN_PASSWORD}" ]; then
echo "=== First run: setting admin password ==="
# Always pass the admin password when we have one.
#
# This used to be gated on the admin DB not existing yet, on the assumption that
# the file's presence meant the admin user was already set up. It does not: PZ
# creates the DB file in ServerWorldDatabase.create() *before* it creates the
# admin user, so any boot interrupted between those two steps leaves a database
# that exists but has no admin. From then on the guard withheld the flag, the
# server fell back to an interactive "Enter new administrator password:" prompt,
# read EOF because a detached container has no stdin, and died with
# NoSuchElementException -- on every restart, forever.
if [ -n "${ADMIN_PASSWORD}" ]; then
SERVER_ARGS="${SERVER_ARGS} -adminpassword ${ADMIN_PASSWORD}"
fi
@@ -12,7 +12,14 @@ force_install_dir /project-zomboid
login anonymous
// Install/Update the Project Zomboid Dedicated Server - Unstable Branch (Build 42)
app_update 380870 -beta unstable validate
// Install/Update the Project Zomboid Dedicated Server (Build 42, stable)
//
// -beta public is required, not optional. Dropping the flag entirely does NOT
// leave a previously-selected beta: SteamCMD writes the new branch to the
// manifest's UserConfig but leaves MountedConfig -- and the actual installed
// content -- on the old one. That failed silently here, leaving the server on
// the 42.14.1 unstable build while the config had already moved to the mod-ID
// syntax that only 42.20+ accepts, so every mod reported "not found".
app_update 380870 -beta public validate
quit
@@ -0,0 +1,30 @@
# {{ ansible_managed }}
#
# Project Zomboid's server console, which is a single ever-growing file. This is
# separate from the container's stdout, which podman rotates itself via the
# k8s-file driver's max_size.
#
# Scope is deliberately just this file. PZ's other logs live in dated
# directories under Logs/ that grow in file *count*, not file size, so nothing
# there ever crosses a rotation threshold -- those are pruned by age instead,
# via zomboid-log-prune.timer.
#
# copytruncate is mandatory here: the server holds these fds open for the whole
# life of the process, so a rename would leave it writing into an unlinked
# inode and the file would appear to simply stop growing.
#
# The Discord bot tails server-console.txt from a byte offset and resets that
# offset when the file shrinks, so a truncate under it is safe.
{{ zomboid_path }}/data/server-console.txt
{
# size, not a time directive: logrotate's daily timer is when this is
# *checked*, 100M is when it actually rotates. Specifying both makes
# logrotate warn that size overrides daily.
copytruncate
size 100M
rotate 3
compress
delaycompress
missingok
notifempty
}
@@ -0,0 +1,443 @@
{#-
Project Zomboid server config -- Sophie 42 preset.
Vendored verbatim from https://github.com/GerDeathstar/sophie-pz
("Sophie 42 Files.zip", uploaded 2026-07-31 == the `b42` release tag),
file Server/sophie-server-42-1.ini.
Only the keys this deployment actually owns are templated -- credentials,
RCON, Discord, backup cadence, and the mod lists. Every gameplay setting
(PVP, safety system, safehouses, sleep, MaxPlayers, PauseEmpty, ResetID,
the welcome message) is left exactly as the preset author shipped it.
Seeded once, then left alone: the deploy task uses force=false so the
server and admins can hand-edit this file between world regenerations.
Re-push it deliberately with -e zomboid_config_force=true.
-#}
{% set rename_map = {} %}
{% for r in zomboid_mods_renamed %}{% set _ = rename_map.update({r.old: r.new}) %}{% endfor %}
{% set resolved_mod_ids = [] %}
{% for m in zomboid_sophie_mod_ids %}{% set _ = resolved_mod_ids.append(rename_map.get(m, m)) %}{% endfor %}
{% set excluded_mod_ids = zomboid_mods_excluded | map(attribute='mod_id') | list %}
{% set excluded_workshop_ids = zomboid_mods_excluded | map(attribute='workshop_id') | list %}
# Players can hurt and kill other players
PVP=true
# PVP is logged to admin chat
PVPLogToolChat=true
# PVP is logged to file
PVPLogToolFile=true
# Game time stops when there are no players online
PauseEmpty=true
# Toggles global chat on or off.
GlobalChat=true
ChatStreams=s,r,a,w,y,sh,f,all
# Clients may join without already having an account in the whitelist. If set to false, administrators must manually create username/password combos.
Open=true
# The first welcome message visible in the chat panel. This will be displayed immediately after player login. you can use RGB colours to chance the colour of the welcome message. You can also use < LINE>, without the space, to create a separate lines within your text. Use: \<RGB:1,0,0> This message will show up red!
ServerWelcomeMessage=Welcome to Project Zomboid Multiplayer! <LINE> <LINE> To interact with the Chat panel: press Tab, T, or Enter. <LINE> <LINE> The Tab key will change the target stream of the message. <LINE> <LINE> Global Streams: /all <LINE> Local Streams: /say, /yell <LINE> Special Steams: /whisper, /safehouse, /faction. <LINE> <LINE> Press the Up arrow to cycle through your message history. Click the Gear icon to customize chat. <LINE> <LINE> Happy surviving!
# Display usernames above player's heads in-game.
DisplayUserName=true
# Display first & last name above player's heads.
ShowFirstAndLastName=false
UsernameDisguises=false
HideDisguisedUserName=false
SwitchZombiesOwnershipEachUpdate=false
# Force every new player to spawn at these set x,y,z world coordinates. Find desired coordinates at map.projectzomboid.com. (Ignored when 0,0,0)
SpawnPoint=0,0,0
# Players can enter and leave PVP on an individual basis. A player can only hurt another player when at least one of them is in PVP mode - as shown by the unobscured skull and crossbones on the left of the screen. When SafetySystem=false, players are free to hurt each other at any time if PVP is enabled.
SafetySystem=true
# Display a skull icon over the head of players who have entered PVP mode
ShowSafety=true
# The time it takes for a player to enter and leave PVP mode Min: 0 Max: 1000 Default: 2
SafetyToggleTimer=2
# The delay before a player can enter or leave PVP mode again, having recently done so Min: 0 Max: 1000 Default: 3
SafetyCooldownTimer=3
# Min: 0 Max: 60 Default: 60
SafetyDisconnectDelay=60
# Item types new players spawn with. Separate multiple item types with commas. Example: Base.Axe,Base.Bag_BigHikingBag
SpawnItems=
# Default starting port for player data. If UDP, this is this one of two ports used. Min: 0 Max: 65535 Default: 16261
DefaultPort=16261
# Min: 0 Max: 65535 Default: 16262
UDPPort=16262
# Reset ID determines if the server has undergone a soft-reset. If this number does match the client, the client must create a new character. Used in conjunction with PlayerServerID. It is strongly advised that you backup these IDs somewhere Min: 0 Max: 2147483647 Default: 985847558
ResetID=6953472
# Enter the mod loading ID here. It can be found in \Steam\steamapps\workshop\modID\mods\modName\info.txt
Mods={{ resolved_mod_ids | reject('in', excluded_mod_ids) | join(';') }}
# Enter the foldername of the mod found in \Steam\steamapps\workshop\modID\mods\modName\media\maps\
Map={{ zomboid_map }}
# Kick clients whose game files don't match the server's.
DoLuaChecksum=false
DenyLoginOnOverloadedServer=true
# Shows the server on the in-game browser. (Note: Steam-enabled servers are always visible in the Steam server browser)
Public=false
# Name of the server displayed in the in-game browser and, if applicable, the Steam browser
PublicName={{ zomboid_public_name }}
# Description displayed in the in-game public server browser. Typing will create a new line in your description
PublicDescription=
# Maximum number of players that can be on the server at one time. This excludes admins.
# WARNING: Server player counts above 32 will potentially result in poor map streaming and desync. Please advance with caution. Min: 1 Max: 100 Default: 32
MaxPlayers=32
# Ping limit, in milliseconds, before a player is kicked from the server. (Set to 0 to disable) Min: 0 Max: 2147483647 Default: 0
PingLimit=0
# Items will not respawn in buildings that players have claimed as a safehouse
SafehousePreventsLootRespawn=true
# Remove player accounts from the whitelist after death. This prevents players creating a new character after death on Open=false servers
DropOffWhiteListAfterDeath=false
# All forms of fire are disabled - except for campfires
NoFire=false
# If checked, every time a player dies a global message will be displayed in the chat
AnnounceDeath=true
# If checked, every time an animal dies a global message will be displayed in the chat
AnnounceAnimalDeath=false
# Loaded parts of the map are saved after this set number of real-world minutes have passed. (The map is usually saved only after clients leave a loaded area) Min: 0 Max: 2147483647 Default: 0
SaveWorldEveryMinutes=10
# Both admins and players can claim safehouses
PlayerSafehouse=false
# Only admins can claim safehouses
AdminSafehouse=false
# Allow non-members to enter a safehouse without being invited
SafehouseAllowTrepass=true
# Allow fire to damage safehouses
SafehouseAllowFire=true
# Allow non-members to take items from safehouses
SafehouseAllowLoot=true
# Players will respawn in a safehouse that they were a member of before they died
SafehouseAllowRespawn=false
# Players must have survived this number of in-game days before they are allowed to claim a safehouse Min: 0 Max: 2147483647 Default: 0
SafehouseDaySurvivedToClaim=0
# Players are automatically removed from a safehouse they have not visited for this many real-world hours Min: 0 Max: 2147483647 Default: 144
SafeHouseRemovalTime=144
# Governs whether players can claim non-residential buildings.
SafehouseAllowNonResidential=false
SafehouseDisableDisguises=true
# Min: 0 Max: 2147483647 Default: 20000
MaxSafezoneSize=20000
# Allow players to destroy world objects with sledgehammers
AllowDestructionBySledgehammer=true
# Allow players to destroy world objects only in their safehouse (require AllowDestructionBySledgehammer to true).
SledgehammerOnlyInSafehouse=false
War=false
# Time in seconds before the war starts. Min: 60 Max: 2147483647 Default: 600
WarStartDelay=600
# War duration in seconds. Min: 60 Max: 2147483647 Default: 3600
WarDuration=3600
# Safehouse hit points limit. Min: 0 Max: 2147483647 Default: 3
WarSafehouseHitPoints=3
# ServerPlayerID determines if a character is from another server, or single player. This value may be changed by soft resets. If this number does match the client, the client must create a new character. This is used in conjunction with ResetID. It is strongly advised that you backup these IDs somewhere
ServerPlayerID=
# The port for the RCON (Remote Console) Min: 0 Max: 65535 Default: 27015
RCONPort={{ zomboid_rcon_port }}
# RCON password (Pick a strong password)
RCONPassword={{ zomboid_admin_password }}
# Enables global text chat integration with a Discord channel
DiscordEnable=true
# Discord bot access token
DiscordToken={{ zomboid_discord_token }}
# The Discord chat channel name
DiscordChatChannel={{ zomboid_discord_chat_channel }}
# The Discord logs channel name
DiscordLogChannel=
# The Discord commands channel name
DiscordCommandChannel=
# The Slack incoming webhook URL
WebhookAddress=
# Clients must know this password to join the server. (Ignored when hosting a server via the Host button)
Password={{ zomboid_password }}
# Limits the number of different accounts a single Steam user may create on this server. Ignored when using the Hosts button. Min: 0 Max: 2147483647 Default: 0
MaxAccountsPerUser=0
# Allow co-op/splitscreen players
AllowCoop=true
# Players are allowed to sleep when their survivor becomes tired, but they do not NEED to sleep
SleepAllowed=false
# Players get tired and need to sleep. (Ignored if SleepAllowed=false)
SleepNeeded=false
# WIP: Activating this setting may cause visual desynchronization of player positions.
KnockedDownAllowed=false
SneakModeHideFromOtherPlayers=true
UltraSpeedDoesnotAffectToAnimals=false
# List Workshop Mod IDs for the server to download. Each must be separated by a semicolon. Example: WorkshopItems=514427485;513111049
WorkshopItems={{ zomboid_sophie_workshop_items | reject('in', excluded_workshop_ids) | join(';') }}
# Show Steam usernames and avatars in the Players list.
SteamScoreboard=false
# Enable the Steam VAC system
SteamVAC=true
# Attempt to configure a UPnP-enabled internet gateway to automatically setup port forwarding rules. The server will fall back to default ports if this fails
UPnP=true
# VOIP is enabled when checked
VoiceEnable=true
# The minimum tile distance over which VOIP sounds can be heard. Min: 0.00 Max: 100000.00 Default: 10.00
VoiceMinDistance=10.0
# The maximum tile distance over which VOIP sounds can be heard. Min: 0.00 Max: 100000.00 Default: 100.00
VoiceMaxDistance=100.0
# Toggle directional audio for VOIP
Voice3D=true
# Min: 10.00 Max: 150.00 Default: 70.00
SpeedLimit=70.0
LoginQueueEnabled=false
# Min: 20 Max: 1200 Default: 60
LoginQueueConnectTimeout=60
# Set the IP from which the server is broadcast. This is for network configurations with multiple IP addresses, such as server farms
server_browser_announced_ip=
# Players can respawn in-game at the coordinates where they died
PlayerRespawnWithSelf=false
# Players can respawn in-game at a split screen / Remote Play player's location
PlayerRespawnWithOther=false
# Governs how fast time passes while players sleep. Value multiplies the speed of the time that passes during sleeping. Min: 1.00 Max: 100.00 Default: 40.00
FastForwardMultiplier=40.0
# Safehouse acts like a normal house if an owner of the safehouse is connected (so secure when the owner is offline)
DisableSafehouseWhenOwnerConnected=false
# Players can create factions when true
Faction=true
# Players must survive this number of in-game days before being allowed to create a faction Min: 0 Max: 2147483647 Default: 0
FactionDaySurvivedToCreate=0
# Number of players required as faction members before the faction owner can create a group tag Min: 1 Max: 2147483647 Default: 1
FactionPlayersRequiredForTag=1
# Disables radio transmissions from players with an access level
DisableRadioStaff=false
# Disables radio transmissions from players with 'admin' access level
DisableRadioAdmin=true
# Disables radio transmissions from players with 'gm' access level
DisableRadioGM=true
# Disables radio transmissions from players with 'overseer' access level
DisableRadioOverseer=false
# Disables radio transmissions from players with 'moderator' access level
DisableRadioModerator=false
# Disables radio transmissions from invisible players
DisableRadioInvisible=true
# Semicolon-separated list of commands that will not be written to the cmd.txt server log. For example: -vehicle. Inputting * means do NOT write any vehicle command. Inputting: +vehicle.installPart means DO write that command
ClientCommandFilter=-vehicle.*;+vehicle.damageWindow;+vehicle.fixPart;+vehicle.installPart;+vehicle.uninstallPart
# Semicolon-separated list of actions that will be written to the ClientActionLogs.txt server log.
ClientActionLogs=ISEnterVehicle;ISExitVehicle;ISTakeEngineParts;
# Track changes in player perk levels in PerkLog.txt server log
PerkLogs=true
# Maximum number of items that can be placed in a container. Zero means there is no limit. (PLEASE NOTE: This includes individual small items such as nails. A limit of 50 will mean only 50 nails can be stored.) Min: 0 Max: 9000 Default: 0
ItemNumbersLimitPerContainer=0
# Number of days before old blood splats are removed.
# Removal happens when map chunks are loaded.
# Zero means they will never disappear Min: 0 Max: 365 Default: 0
BloodSplatLifespanDays=0
# Allow use of non-ASCII (cyrillic etc) characters in usernames
AllowNonAsciiUsername=false
BanKickGlobalSound=true
# If enabled, when HoursForCorpseRemoval triggers, it will also remove player's corpses from the ground.
RemovePlayerCorpsesOnCorpseRemoval=false
# If true, player can use the "delete all" button on bins.
TrashDeleteAll=false
# If true, player can hit again when struck by another player.
PVPMeleeWhileHitReaction=false
# If true, players will have to mouse over someone to see their display name.
MouseOverToSeeDisplayName=true
# If true, automatically hide the player you can't see (like zombies).
HidePlayersBehindYou=true
# Damage multiplier for PVP melee attacks. Min: 0.00 Max: 500.00 Default: 30.00
PVPMeleeDamageModifier=30.0
# Damage multiplier for PVP ranged attacks. Min: 0.00 Max: 500.00 Default: 50.00
PVPFirearmDamageModifier=50.0
# Modify the range of zombie attraction to cars. (Lower values can help with lag.) Min: 0.00 Max: 10.00 Default: 0.50
CarEngineAttractionModifier=0.5
# Governs whether players bump (and knock over) other players when running through them.
PlayerBumpPlayer=false
# Controls display of remote players on the in-game map. 1=Hidden 2=Friends 3=Friends and nearby players 4=Everyone Min: 1 Max: 4 Default: 1
MapRemotePlayerVisibility=1
# Min: 1 Max: 300 Default: 5
BackupsCount=10
BackupsOnStart=true
BackupsOnVersionChange=true
# Min: 0 Max: 1500 Default: 0
BackupsPeriod=30
# Disables vehicle towing
DisableVehicleTowing=false
# Disables trailer towing
DisableTrailerTowing=false
# Disables burnt vehicle towing
DisableBurntTowing=false
# Path to the file with the list of words to be prohibited. Each word must be on a separate line
BadWordListFile=
# Path to the file with the list of words that should be allowed, even if they contain bad word. Each word should be on a separate line.
GoodWordListFile=
# What to do with the sender of a bad word in the chat: 1 - ban, 2 - kick, 3 - record the violation in the database, 4 - mute
BadWordPolicy=3
# The symbol or piece of text that will replace the bad word
BadWordReplacement=[HIDDEN]
# Disables safety system anti-cheat protection.
AntiCheatSafety=2
# Disables character speed anti-cheat protection.
AntiCheatSpeed=2
# Disables character no-clipping anti-cheat protection.
AntiCheatNoClip=4
# Disables character hit anti-cheat protection.
AntiCheatHit=2
# Disables packet exception checks anti-cheat protection.
AntiCheatPacketException=4
# Disables player permissions anti-cheat protection.
AntiCheatPermission=2
# Disables player XP anti-cheat protection.
AntiCheatXP=2
# Disables safehouse anti-cheat protection.
AntiCheatSafeHouse=2
# Disables player anti-cheat protection.
AntiCheatPlayer=2
# Disables checksum anti-cheat protection.
AntiCheatChecksum=2
# Sets the multiplayer update period in seconds. Statistics is disabled if value is 0. Min: 0 Max: 10 Default: 1
MultiplayerStatisticsPeriod=1
# Disables scoreboard.
DisableScoreboard=false
# Hides admins in the player list.
HideAdminsInPlayerList=false
# Sets the limit for processing network packets from each client on the server. Min: 100 Max: 1000 Default: 300
MaxPacketsPerSecond=300
# Shows player character coordinates in the lower right corner.
ShowCoordinates=false
# The worldgen seed used to generate the world.
# If you want to change this, put a new value in and delete map_worldgen.bin in your save directory.
Seed=DMCERZSTKtuzgukR
UsePhysicsHitReaction=false
# Min: 64 Max: 1024 Default: 200
ChatMessageCharacterLimit=200
# Min: 1 Max: 30 Default: 3
ChatMessageSlowModeTime=3
@@ -6,7 +6,7 @@ set -e
LOGFILE="{{ podman_home }}/.local/share/volumes/zomboid/logs/world-reset.log"
TRIGGER_FILE="{{ podman_home }}/.local/share/volumes/gregtime/data/zomboid-reset.trigger"
SERVER_NAME="{{ zomboid_server_names[zomboid_server_mode] }}"
SERVER_NAME="{{ zomboid_server_name }}"
SAVES_PATH="{{ podman_home }}/.local/share/volumes/zomboid/data/Saves/Multiplayer/${SERVER_NAME}"
DB_PATH="{{ podman_home }}/.local/share/volumes/zomboid/data/db/${SERVER_NAME}.db"
@@ -0,0 +1,6 @@
[Unit]
Description=Prune Zomboid log archive older than {{ zomboid_log_retention_days }} days
[Service]
Type=oneshot
ExecStart={{ podman_home }}/bin/zomboid-log-prune.sh
@@ -0,0 +1,24 @@
#!/bin/bash
# Prune Project Zomboid's own log archive.
#
# PZ rolls its logs into dated directories under Logs/ (logs_YYYY-MM-DD/, plus
# loose YYYY-MM-DD_HH-MM_*.txt at the top) and never removes them. That is not
# something logrotate can bound: it rotates by size, and no individual file here
# ever reaches a sensible threshold -- the growth is in the *number* of files.
# 112 directories and 146k files had accumulated to 18 GB before this existed.
#
# Files are owned by the container's UID, so this runs under podman unshare.
set -euo pipefail
LOGS="{{ zomboid_path }}/data/Logs"
DAYS={{ zomboid_log_retention_days }}
[[ -d "$LOGS" ]] || exit 0
before=$(podman unshare du -sh "$LOGS" 2>/dev/null | cut -f1)
podman unshare find "$LOGS" -type f -mtime +"$DAYS" -delete 2>/dev/null || true
# Second pass: the dated directories are empty once their files are gone.
podman unshare find "$LOGS" -mindepth 1 -type d -empty -delete 2>/dev/null || true
after=$(podman unshare du -sh "$LOGS" 2>/dev/null | cut -f1)
echo "zomboid log prune: keeping ${DAYS}d, ${before} -> ${after}"
@@ -0,0 +1,10 @@
[Unit]
Description=Prune the Zomboid log archive daily
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=30m
[Install]
WantedBy=timers.target
@@ -0,0 +1,562 @@
---
# Sophie 42 modlist -- vendored verbatim from the upstream server preset.
#
# Source: https://github.com/GerDeathstar/sophie-pz ("Sophie 42 Files.zip",
# uploaded 2026-07-31, identical to the `b42` release tag)
# Origin: Server/sophie-server-42-1.ini, keys `Mods=` and `WorkshopItems=`
#
# Kept as lists rather than the semicolon blobs the INI uses so that a Sophie
# update produces a reviewable diff. `templates/zomboid/server.ini.j2` joins
# them back with `;` after subtracting `zomboid_mods_excluded`.
#
# ORDER IS SIGNIFICANT for zomboid_sophie_mod_ids -- it is the mod load order.
# Preserve upstream ordering exactly when re-vendoring; do not sort.
#
# No `\` prefix: Build 42 required one only during its unstable period and
# rejects it on 42.20+.
# Mod IDs (from each package's mod.info) -- the `Mods=` server key.
zomboid_sophie_mod_ids:
- "ModLoadOrderSorter_b42"
- "damnlib"
- "YAPZLib"
- "ChuckleberryFinnAlertSystem"
- "errorMagnifier"
- "ZomboidPaperAPI"
- "MoodleFramework"
- "moodlesinlua"
- "NeatUI_Framework"
- "HBVCEFb42"
- "KATTAJ1_ClothesCore"
- "SpnOpenClothBase"
- "TombBody"
- "isoContainers"
- "PROJECTRVInterior42"
- "LargeProgressIndicator"
- "CleanUI"
- "MiniHealthPlus"
- "BravoItemDetails"
- "DetailedDescriptionsForOccupationsAndTraits"
- "MutiesContextMenuIcons"
- "ItemCondition_KingEJ"
- "SPNCC"
- "SPNCCDetails"
- "SPNCCDetailsHD"
- "SeedSeasonIndicator"
- "NeatUI_Hairstyler"
- "Neat_Building"
- "Neat_Building_AddonXP"
- "Neat_Crafting"
- "Neat_Crafting_AddonXP"
- "DynamicMoodles"
- "devcattpixelmoodles"
- "redrawnmoodles"
- "PMResurrection"
- "PlainMoodles_by_Slobodskoy"
- "quarterbordermoodles"
- "SimpleMoodleIndicators"
- "v1centesmoodles"
- "v1centesmoodlesbeta"
- "SandboxSettings"
- "SimpleOverhaulTraitsAndOccupations"
- "BeyondTen"
- "HasResearch"
- "WorkingKnowledge"
- "SkillRecoveryJournal"
- "M13ReadingTweaks"
- "P4HasBeenRead"
- "P4PickingMeister"
- "P4TidyUpMeister"
- "KillCount"
- "CausaMortis"
- "KI5campers"
- "KI5trailers"
- "TradingTrailer"
- "49powerWagon"
- "59meteor"
- "63beetle"
- "63Type2Van"
- "65banshee"
- "66pontiacLeMans"
- "67commando"
- "67gt500"
- "68firebird"
- "69camaro"
- "69charger"
- "69fordMustang"
- "69fordMustangExtra"
- "69mini"
- "69mini_ItalianJob"
- "69mini_PitbullSpecial"
- "70barracuda"
- "70chevelle"
- "70dodge"
- "70fordEscort"
- "70roadRunner"
- "73fordFalcon"
- "73fordFalconPS"
- "73nissanGTR"
- "75grandPrix"
- "76chevyKseries"
- "76chryslerNewYorker"
- "77firebird"
- "78amgeneralM35A2"
- "78amgeneralM35A2extra"
- "78amgeneralM49A2C"
- "78amgeneralM50A3"
- "78amgeneralM62"
- "78lamboCountach"
- "79camaro"
- "80manKat1"
- "81deloreanDMC12"
- "82firebird"
- "82jeepJ10"
- "82jeepJ10t"
- "82oshkoshM911"
- "82porsche911"
- "84buickElectra"
- "84cadillacDeVille"
- "84corvette"
- "84jeepXJ"
- "84merc"
- "84oldsmobile98"
- "85buickLeSabre"
- "85chevyCaprice"
- "85chevyStepVan"
- "85chevyStepVanexpanded"
- "85oldsmobileDelta88"
- "85pontiacParisienne"
- "86chevyCUCV"
- "86fordE150"
- "86fordE150expanded"
- "86fordE150pd"
- "86oshkoshP19A"
- "87buickRegal"
- "87chevySuburban"
- "87fordB700"
- "87toyotaMR2"
- "88chevyS10"
- "88toyotaHilux"
- "89defender"
- "89dodgeCaravan"
- "89fordBronco"
- "89trooper"
- "89volvo200"
- "90bmwE30"
- "90fordF350ambulance"
- "90pierceArrow"
- "91fordLTD"
- "91fordRanger"
- "91geoMetro"
- "91lexusLS400"
- "91nissan240sx"
- "91range"
- "92amgeneralM998"
- "92amgeneralM998extra"
- "92fordCVPI"
- "92jeepYJ"
- "92jeepYJJP18"
- "92nissanGTR"
- "93chevySuburban"
- "93chevySuburbanExpanded"
- "93fordElgin"
- "93fordF350"
- "93fordTaurus"
- "93mustangSSP"
- "93townCar"
- "99fordCVPI"
- "ReactiveSE"
- "HereGoesTheSun"
- "RainCleansBlood"
- "CheckthePapers"
- "DeadMansDossier"
- "KnoxDetectionKit"
- "ZVirusVaccine42BETA"
- "LifestyleHobbies"
- "GuitarMusicAddon"
- "DeltaSeries"
- "AP"
- "AutoReload"
- "AutoTailoring"
- "better-auto-mechanics"
- "CaffeineMakesSense"
- "Drink_Smart"
- "dustinguished_bolt_cutters"
- "FasterHoodOpening"
- "FWOFitnessWorkoutOverhaul"
- "FWOBenchPress&Treadmill"
- "KeepRadioOnVanillaFriendly"
- "MoreUsesForLightersAndMatches"
- "NoLighterNeeded"
- "OCsPacking"
- "SmarterStorage"
- "STA_PryOpen"
- "stanks_suicide"
- "SwapIt"
- "throw-your-bag-across"
- "UseItemsWhileWalkingB42"
- "OpenAllContainers"
- "RebalancedPropMoving"
- "RepairAnyClothes"
- "ReducedWeightWalkieTalkies"
- "WashFix"
- "w_betterwringing"
- "DRAW_ON_MAP"
- "FirstAidVHSTapes"
- "NepCanteenSlot"
- "TacHold AR"
- "TacHold Pistol AR"
- "ShelterHold_Beehive"
- "Ladders42131"
- "JWJ"
- "ClothingLine"
- "ComputerModkum"
- "Buttstroke"
- "CVI"
- "CVLS"
- "FunctionalAppliances2"
- "JB_BugsInLights"
- "LWBetterElectronics"
- "MoreDamagedObjects"
- "PIP"
- "playable_pool"
- "PSR"
- "RepairWallCracks_B42"
- "SomewhatWater"
- "Snowman"
- "SkillBookExpansionB42"
- "UseulTents"
- "WorkingVending"
- "Video_Game_Consoles"
- "VGC_Addon_NESGames"
- "VGC_Addon_SEGAGames"
- "VGC_Addon_SNESGames"
- "VGC_Addon_SNESGames2"
- "TVRadio_ReInvented"
- "NewMusic"
- "NewMusicG70s"
- "NewMusicG80s"
- "NewMusicG90s"
- "NMGreatestHits"
- "NewMusic_OrchestraMix"
- "Project_Cook"
- "B42Horticulture"
- "SKITTLE_LongTermPreservation4220"
- "VanillaFoodsExpanded"
- "SapphCooking_B42"
- "Makedriedfruit"
- "Makedriedvegetable"
- "Makefruitinjar"
- "Mushroomjar"
- "ServingPlatesB42"
- "Edwin'sCannedTent"
- "Edwin'sCannedTobaccoKit"
- "Edwin'sSurvivalTabsMod"
- "dustinguished_cleaning_wipes"
- "ExtraBombs2"
- "ammomaker"
- "B42Survival"
- "SWMG"
- "MarzGuns"
- "LEGION18"
- "SpnOpenCloth"
- "SpnCloth"
- "FH"
- "SpnHair"
- "VanillaGearExpanded"
- "ArmoredVests"
- "ArmoredVestsPatch"
- "AliceGear"
- "[J&G] Firefighter Uniform"
- "[J&G] SWAT Uniform"
- "[J&G] Trauma Responder Uniform"
- "[J&G] Desert Multicam Uniform"
- "DarkWpnSlings"
- "GanydeBielovzki's Frockin Splendor!"
- "GanydeBielovzki's Frockin Splendor! Vol.2"
- "GanydeBielovzki's Frockin Splendor! Vol.3"
- "GanydeBielovzki's Frockin Splendor! Vol.4"
- "GanydeBielovzki's Frockin Splendor! Vol.5"
- "GanydeBielovzki's Frockin Shirts n Ties"
- "GanydeBielovzki's Frockin Wiseguys"
- "GanydeBielovzki's Frockin Stompers! VFR"
- "CorvusNVG"
- "AccCamos"
- "AccCamosPeriodPack"
- "TombBodyCompat"
- "TombBodyTex"
- "TombBodyCustom"
- "ProjectArcade"
- "BCR"
- "DG_MIVehicles"
- "WayMoreCars"
- "VMZNEW"
- "VVR"
- "RealisticDash"
- "GasPumpIndicator"
- "immersive_hotwire"
- "NepHighBeams"
- "TaillightsAndStoplights"
- "VehicleDoorsHotkey"
- "VehicleRepairOverhaul"
- "VehicleSalvageOverhaulB42"
# Numeric Steam Workshop page IDs -- the `WorkshopItems=` server key.
# These are what SteamCMD actually downloads.
zomboid_sophie_workshop_items:
- "3423660713"
- "3171167894"
- "3624971238"
- "3077900375"
- "2896041179"
- "3155647374"
- "3396446795"
- "3395171770"
- "3508537032"
- "3610677934"
- "3470422050"
- "2812326159"
- "3429790870"
- "2625625421"
- "3543229299"
- "2926455909"
- "3437629766"
- "3710913197"
- "3477237184"
- "3387957272"
- "3101668503"
- "3641048285"
- "3414634809"
- "3734639991"
- "3760534563"
- "3536052310"
- "3540503606"
- "3502080466"
- "3674800555"
- "3417051694"
- "3386486141"
- "3569718104"
- "3772979228"
- "3391323469"
- "3402171609"
- "3389609367"
- "3386906181"
- "2840805724"
- "3765241705"
- "3567695139"
- "3717099183"
- "2503622437"
- "2776874515"
- "2544353492"
- "3422220305"
- "2769706949"
- "2553809727"
- "3711044244"
- "3670064951"
- "3330403100"
- "3572122233"
- "2900580391"
- "2772575623"
- "3005903549"
- "3041122351"
- "3566868353"
- "3447272250"
- "2478247379"
- "3026723485"
- "3258343790"
- "2991201484"
- "3631989559"
- "3756938756"
- "2937786633"
- "2913633066"
- "3766571591"
- "2873290424"
- "3670063857"
- "3642935062"
- "3490370700"
- "3743371090"
- "3213391371"
- "3161951724"
- "3730833846"
- "3346905070"
- "2799152995"
- "3726526329"
- "3703948448"
- "3248388837"
- "3253385114"
- "3320947974"
- "2886832257"
- "2618213077"
- "3379334330"
- "3596903773"
- "3592777775"
- "3684254299"
- "3409287192"
- "2805630347"
- "3601417745"
- "3418252689"
- "3413704851"
- "3614034284"
- "3418253716"
- "3413706334"
- "3428008364"
- "2870394916"
- "2566953935"
- "3226885926"
- "3196180339"
- "3110911330"
- "3052360250"
- "2886832936"
- "3435796523"
- "3570973322"
- "3034636011"
- "2886833398"
- "2932549988"
- "3292659291"
- "3110913021"
- "2952802178"
- "2942793445"
- "3366300557"
- "3539691958"
- "3008795514"
- "3770890864"
- "3504401781"
- "2409333430"
- "2642541073"
- "2962175696"
- "3287727378"
- "2846036306"
- "3152529790"
- "2969343830"
- "3073430075"
- "3088951320"
- "3001592312"
- "2932547723"
- "2971246021"
- "3656190498"
- "3618557184"
- "2956146279"
- "3580513066"
- "3675740871"
- "3688879406"
- "3615135168"
- "3403870858"
- "3412944806"
- "3660235667"
- "3766508989"
- "3389448389"
- "3388183573"
- "3635856965"
- "3682557825"
- "3447775367"
- "3671176591"
- "2584112711"
- "2940354599"
- "3558422176"
- "3533994661"
- "2714198296"
- "3626823538"
- "3290232938"
- "3579640010"
- "3426448380"
- "2366717227"
- "3391902125"
- "3615422211"
- "3465040406"
- "2699828474"
- "2142622992"
- "2981090458"
- "3460812542"
- "3408740647"
- "2804531012"
- "3153010942"
- "3393080905"
- "3677017550"
- "3680633169"
- "3596827035"
- "3629835761"
- "2868828908"
- "3282215361"
- "3725497089"
- "3394044313"
- "3749026793"
- "3719237374"
- "3042138819"
- "3717902500"
- "2999183635"
- "3413150945"
- "3739173520"
- "3716934959"
- "3725311427"
- "3684808606"
- "3582960654"
- "3669451917"
- "3625874308"
- "2934155949"
- "2831786301"
- "3413237147"
- "3423725989"
- "3408186716"
- "3488113291"
- "3420581050"
- "3739256725"
- "3765077081"
- "3765079521"
- "3765670104"
- "3751364201"
- "3760471726"
- "3490188370"
- "3650168851"
- "3774789651"
- "3577903007"
- "3409143790"
- "3417502641"
- "3418061837"
- "3432006285"
- "3416833689"
- "3399320470"
- "3453369692"
- "3455296935"
- "3453175422"
- "3755185651"
- "3652008781"
- "2788256295"
- "3402516345"
- "3722064198"
- "3722134990"
- "3665548194"
- "3660382016"
- "3557111695"
- "2684285534"
- "2447729538"
- "2463184726"
- "3401134276"
- "1962761540"
- "3610005735"
- "3521263771"
- "3457969588"
- "3443982326"
- "3299457564"
- "3307376332"
- "3411888105"
- "3769335201"
- "3431256608"
- "3475347500"
- "3635394848"
- "3453676250"
- "3538760023"
- "3736813592"
- "3454414190"
- "3431734923"
- "3645980077"
- "3162566044"
- "3520758551"
- "3436499337"
- "3423424077"
- "3635591071"
- "3755993986"
- "3653092321"
- "3438126404"
- "3687394815"
- "2290459371"
- "2757712197"
Binary file not shown.
+15 -12
View File
@@ -15,7 +15,7 @@ Usage:
# Output formats
--json Output raw JSON data
--ansible Output workshop_items and mod_ids strings for ansible config
--ansible Output the modlist as YAML lists for vars/zomboid_sophie_mods.yml
--report Human-readable report (default)
Examples:
@@ -302,18 +302,21 @@ def output_ansible(data: Dict) -> None:
seen_mod_ids.add(mod_id)
mod_ids.append(mod_id)
# Format for Build 42 (backslash prefix)
workshop_str = ";".join(workshop_items)
mod_ids_str = ";".join(f"\\{mid}" for mid in mod_ids)
print("\n# Ansible Configuration for zomboid_mods")
print("# Copy these values to ansible/roles/podman/defaults/main.yml")
# No backslash prefix: Build 42 required one only during its unstable
# period and rejects it on 42.20+.
print("\n# Modlist for ansible/roles/podman/vars/zomboid_sophie_mods.yml")
print("# Replaces the file body wholesale -- keep the header comment.")
print("")
print("zomboid_mods:")
print(" workshop_items: >-")
print(f" {workshop_str}")
print(" mod_ids: >-")
print(f" {mod_ids_str}")
print("# Mod IDs (from each package's mod.info) -- the `Mods=` server key.")
print("# ORDER IS SIGNIFICANT: this is the mod load order. Do not sort.")
print("zomboid_sophie_mod_ids:")
for mod_id in mod_ids:
print(f" - {json.dumps(mod_id, ensure_ascii=False)}")
print("")
print("# Numeric Steam Workshop page IDs -- the `WorkshopItems=` server key.")
print("zomboid_sophie_workshop_items:")
for workshop_id in workshop_items:
print(f' - "{workshop_id}"')
if data["duplicates"]:
print("\n# WARNING: The following Mod IDs had duplicates (kept first occurrence):")