Skip to content

feat: installer hardening flags and one source for pinned versions - #49

Merged
agentHits merged 33 commits into
AgentHits-Devfrom
feat/server-hardening-pinned-versions
Oct 8, 2026
Merged

agentHits merged 33 commits into
AgentHits-Devfrom
feat/server-hardening-pinned-versions

Conversation

@agentHits

Copy link
Copy Markdown
Collaborator

What is this PR about?

Two related changes for the installer and for the server setup that the panel runs.

  1. Hardening that the Security tab checks (SSH keys only, UFW, Fail2Ban). It is available as opt-in flags for the panel installer and as the DOKPLOY_SERVER_HARDENING setting for servers that the panel sets up. Everything is off by default.
  2. One source for component versions (PINNED_VERSIONS). The panel installer, the remote server setup, the Dockerfiles, the CI workflow and the application Railpack picker read the same values, and a test fails when they drift. The pins move to current stable releases: Node 24.21.0, Docker 29.8.2, Traefik 3.7.14, Postgres 18.6, Redis 8.10.2, Nixpacks 1.41.0, Railpack 0.40.1, pack 0.40.9 and RClone 1.75.1. A Components button on each server updates the installed components to these versions.

Why: a freshly installed panel and servers added later should get identical versions, and an already set up server should be updatable from the panel without SSH.

Reviewer notes

  • Railpack 0.15.4 → 0.40.1 and pack 0.39.1 → 0.40.9 are large jumps. Existing applications keep the railpackVersion stored in their row; the database default (0.15.4) is unchanged and needs a migration to change. New applications get 0.40.1 in the form. Please check a real Railpack build before rollout.
  • UFW does not block ports published by Docker. Checked on a test server: a container published on port 8088 answered from the internet while UFW denied incoming. Blocking such ports (DOCKER-USER, or binding to 127.0.0.1) is not part of this PR.
  • UsePAM no (required by the Security tab) disables pam_limits and pam_systemd for SSH sessions. Login by key was checked.
  • Not checked here: the Windows launcher (no PowerShell on the machine used), a macOS run on real hardware, UFW inside WSL, and the Dockerfile build (CI builds it).
  • Local results: tsc is clean for packages/server and apps/dokploy, Biome is clean, and the full apps/dokploy run passes except one file that needs Docker in swarm mode (application.real.test.ts), which is unrelated to this change. If TypeScript reports errors in @better-auth/scim or better-auth locally, node_modules is stale: bun install --frozen-lockfile fixes it; the lockfile is unchanged.
  • The base is the repository default branch, so the template's canary item does not apply.

Checklist

  • Dedicated branch, based on the repository default branch.
  • CONTRIBUTING.md was read. (Not done.)
  • Tested in a local instance. (Tests and a container install were run; the panel UI was not run locally.)

Issues related (if applicable)

None.

Screenshots (if applicable)

Not applicable.

🤖 Generated with Claude Code

agentHits and others added 30 commits October 8, 2026 07:57
Installer hardening (opt-in, Linux only):
- install-agenthits.sh accepts --harden, --ufw, --ssh-keys-only and
  --fail2ban, plus a harden mode for a host that is already installed.
  The same switches are read as HARDEN_SSH, HARDEN_UFW and HARDEN_FAIL2BAN,
  so the macOS (OrbStack machine) and Windows (WSL) launchers pass them on.
- SSH keys-only writes the main sshd_config, because the Security tab reads
  that file and not drop-ins. It refuses to run without a valid root key and
  skips hosts that have no sshd.
- UFW denies incoming traffic except SSH, 80, 443 (tcp, and udp for HTTP/3)
  and keeps forwarding enabled for Docker. Fail2Ban runs the sshd jail in
  aggressive mode from /etc/fail2ban/jail.local. UFW and Fail2Ban are
  Debian/Ubuntu only and are skipped elsewhere.

Panel-driven server setup:
- DOKPLOY_SERVER_HARDENING (ssh, ufw, fail2ban or all) applies the same
  hardening as the last step of Setup Server. It is off by default. The
  installer sets it from its flags.

One source for component versions:
- packages/server/src/setup/component-versions.ts (PINNED_VERSIONS) pins
  Node 24.21.0, Docker 29.8.2, Traefik 3.7.14, Postgres 18.6, Redis 8.10.2,
  Nixpacks 1.41.0, Railpack 0.40.1, pack 0.40.9 and RClone 1.75.1.
- The installer defaults, remote server setup, both Dockerfiles, CI, and the
  application Railpack picker read these values. component-versions-sync
  fails when a file drifts from the object.
- Remote setup installs the pinned RClone release archive. The Ubuntu 26.04
  Docker repin to 29.4.2 is gone, because 29.8.2 is published for noble and
  resolute on amd64 and arm64.
- Setup Server still installs only what is missing. A Components button on
  each server compares installed and pinned versions and updates Docker,
  Traefik, RClone, Nixpacks, Railpack and pack. Docker updates restart the
  daemon and Traefik is recreated; the dialog warns about both.
- The panel installer installs Nixpacks, Railpack, pack and RClone on the
  host at the pinned versions.

Fix:
- Nixpacks and Railpack installed through sudo ignored the pinned version,
  because sudo resets the environment. The version is passed with env now.

Docs: docs/server-hardening.md (flags, rollback, removing a server) and
docs/server-components-update.md (pins, update behaviour, limits).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
New applications now store Railpack 0.40.1 as their default version, matching
the pinned version the installers and the remote setup use. Migration 0207
only changes the column default: existing applications keep the version they
have stored.

Checked on a throwaway Postgres 18.6 instance: a row inserted before the
migration keeps 0.15.4, and a row inserted after it without the column gets
0.40.1. drizzle-kit reports no further schema changes.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The runbook still named Traefik 3.7.5 and a test file that was replaced
by component-versions-sync.test.ts. Its update example also set
TRAEFIK_VERSION=3.7.5 on the panel, which would make new servers install
the old Traefik instead of the pinned 3.7.14. The example now only adds
DOKPLOY_SERVER_HARDENING; the installer sets TRAEFIK_VERSION from
TRAEFIK_IMAGE on its own.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Two tests in the settings suite spawn bash or sh against fake docker and
curl scripts. They pass locally in about 3s, but on the CI runner, which
also runs real Docker builds in parallel, they exceeded vitest's 5s
default (pr-check run 37777323548). Raise the timeout only for the tests
that spawn processes. Most of the diff is biome reindenting the it()
calls whose timeout argument moved to its own line.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The panel self-update passed no failure action, so Swarm's default (pause)
applied: when the new task failed, the panel stayed down with no return to
the previous image. Both self-update commands now pass
--update-failure-action rollback, and state --update-order stop-first
explicitly.

Stop-first is deliberate. The panel runs drizzle migrations when its
container starts (apps/dokploy/migration.ts, run before server.ts), so an
overlapping old and new task would migrate and serve the same schema at
once. The image is pulled before the update, so the gap is only container
start time.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The Components update removed the running Traefik container before the new
image was local, and the Docker step restarted the daemon before anything
else was fetched. Now one gate runs before every step: it pulls the pinned
Traefik image, fetches the get.docker.com script and, on apt hosts,
downloads the pinned Docker packages. Any failure aborts with "Nothing was
changed" while every running service is still untouched.

Traefik is swapped without removing the running container first. The old
container is stopped and renamed, the new one is started and connected to
the network, and the old one is removed only after the new one has been
running for TRAEFIK_SETTLE_SECONDS (10 by default). If the new container
fails to start or is not running, the old one is renamed back, started, and
the script exits non-zero. ERR and HUP/INT/TERM traps cover a failed run or
network connect and an SSH session that drops mid-swap.

Docker keeps its daemon restart. The Docker documentation scopes live-restore
to standalone containers and states that it does not apply to Swarm services,
which are the workload on these hosts, so daemon.json is not changed.

The "recreates the Traefik container" test asserted the remove-first step and
now asserts the rename. New tests run the generated script against fake
docker, apt-get, apt-cache, curl and sudo commands to check the order and the
restore paths.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
update.sh ran an attached docker service update. Attached mode has no
timeout, and it reports a completed rollback as success, so a panel update
that rolled back looked like a success. The update is now submitted with
--detach, and the script waits until the update is registered (the service
Version.Index has moved) before it reads UpdateStatus. It fails on a paused
or rolled-back state, and when the new task is not healthy within
AGENTHITS_HEALTH_TIMEOUT (240 seconds by default).

The update-script tests fake the service index, the update state and the
container health. A new case covers a rollback.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
install-agenthits.sh update replaced only the panel image. It now runs the
whole host stack, after one pre-download gate that pulls every pinned image
(Postgres, Redis, Traefik, the panel) and fetches update.sh before anything
is changed. Then, in order:

1. Postgres backup: pg_dumpall to AGENTHITS_BACKUP_DIR, kept on the host.
   It runs before any swap, so a failed backup changes nothing.
2. Redis: stop-first swarm update with rollback on failure; waits for PONG.
3. Traefik: the running container is stopped and renamed, the new one is
   started and joined to dokploy-network, and it must still be running after
   AGENTHITS_TRAEFIK_SETTLE seconds. If it is not, the old container is put
   back. The old container stays stopped as dokploy-traefik-previous with
   restart=no, so a daemon restart cannot start it next to the new one.
4. Postgres: stop-first swarm update, which the shared data volume requires;
   waits for pg_isready.
5. Panel: update.sh, which waits for health and rolls back on failure.
6. Docker Engine, only with DOCKER_ENGINE_UPGRADE=1: the packages are
   downloaded in the gate and installed with --no-download after the rest is
   healthy. The daemon restart is the one disruption that remains.

Order: Redis and Traefik fail before the database changes. Postgres follows
its backup and comes before the panel, because the new panel migrates the
database when it starts. The panel is last, so its dependencies are on their
pinned versions and healthy before its update runs.

The fresh install now calls run_dokploy_traefik for its Traefik container
with the same flags, so both paths share one definition.

install-agenthits-update.test.ts runs the installer in update mode against
fake docker, apt, curl, id and uname. It checks the order, that a failed
pre-download or backup changes nothing, that a rolled-back Redis stops the
update, and that a Traefik that does not start is restored.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
agenthits-install.md now lists the operator update steps in order, the
pre-download gate, the Postgres backup location, the Traefik keep-until-
verified swap, the optional Docker Engine upgrade and the manual rollback
commands. server-components-update.md states the pre-download gate, the
Traefik restore behaviour, why live-restore is not used for swarm services,
and that pre-downloading Docker packages needs apt. server-hardening.md
notes that the update path leaves SSH, UFW and Fail2Ban unchanged.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The settings added for the operator update (backup directory, health timeout
and interval, Traefik settle time) now use the DOKPLOY_ prefix the installer
already uses for its own settings. Their messages no longer carry the fork
name, and the default backup directory is /var/backups/dokploy.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
pg_dumpall writes the data and the role password hashes. The backup was
created with the default umask, so the file could be world-readable. The
backup directory is now created 0700 and the dump runs under umask 077, so
the file is 0600. The installer test checks the mode of the file it writes.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The Postgres swap keeps the existing volume mount, so a new major version
does not read the old data directory. The PostgreSQL 18 image would start a
new, empty cluster while the panel reconnects to it. The update now compares
the running and pinned major versions before the pre-download gate and stops
with "Nothing was changed" when they differ. The major version check reuses
get_postgres_major_version, which now takes an optional image argument.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
mkdir -p -m applies the mode only to the deepest directory, which shellcheck
flags as SC2174. The directory is created with mkdir -p and then chmod 700,
which is the same result without the ambiguity.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
update.sh installed Docker 28.5.0 on a host without Docker, while the
installer and the component pins use 29.8.2. Read the version from
DOCKER_VERSION with the same default as the installer, and extend the
version sync test to cover update.sh.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The Docker Engine upgrade stays off unless DOCKER_ENGINE_UPGRADE=1 is set in
the environment of the update command; the macOS and WSL launchers now pass
it on. Before the Engine is touched, the installed packages the upgrade
replaces are saved under DOKPLOY_BACKUP_DIR/docker-rollback-<UTC>/ (directory
0700, files 0600). After the upgrade the stack must report the pinned version
with an active swarm and healthy panel, Postgres, Redis and Traefik; if not,
the saved packages are installed back and the stack is checked again. When
the rollback fails too, the commands to finish it by hand are printed.

The Docker apt source is compared with VERSION_CODENAME before anything
changes, and a mismatch stops the update without rewriting the source.

Traefik is recreated with docker create, connected to dokploy-network and to
every other network of the old container (bridge excluded), and only then
started. /etc/dokploy is 755 instead of 777.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The component update installs the pinned Docker from the packages downloaded
before any step runs, with apt-get install --no-download, instead of running
get.docker.com a second time. get.docker.com is still fetched only on hosts
without apt-get, where nothing can be pre-downloaded or rolled back.

Before anything changes, the Docker apt source is compared with
VERSION_CODENAME. When it names another release only the Docker step is
skipped, with a message, and the other components still run. When no Docker
source exists and apt does not offer the pinned version, Docker's repository
is added for ubuntu and debian; an existing source is never rewritten.

The packages that the upgrade replaces are saved first, as in the panel host
installer. If the daemon does not report the pinned version, or Traefik is not
running afterwards, the saved packages are installed back, Traefik is started
and the step fails.

Traefik is created with docker create, connected to dokploy-network and to
every other network of the old container (read on the host; bridge excluded),
and only then started. The old container is stopped just before the new one
starts, so the time without a running Traefik stays short.

The Docker step is opt-in: filterDockerUpgrade removes docker from the list
unless upgradeDocker is set, and logs one line saying so.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
…s dialog

The component update takes upgradeDocker (default false). The Components
dialog shows an unchecked "Upgrade Docker Engine (restarts all containers on
this server)" checkbox whenever Docker is among the outdated components, and
sends its value with the update. Without it, Docker is skipped and the update
says so in its log.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Explains that the panel host upgrade of the Docker Engine needs
DOCKER_ENGINE_UPGRADE=1 in the environment of the update command, what is
saved for rollback and where, the checks after the upgrade, and the commands
to roll back by hand. Describes the new order in which Traefik is replaced.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
… old container

Before the replaced Traefik container is stopped, its restart policy is set to
no, so a daemon restart cannot start it next to the new container. When the
old container is put back, its restart policy is set to always before it is
started. The panel host installer and the remote server update follow the
same order.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
…ad failures

A Docker-specific failure skips the Docker step with one message and lets the
other components run: a Docker apt source for another release, no pinned
candidate after the repository check, an unsupported distribution, a failure
to save the rollback packages (including hosts without docker-ce), and a host
without apt-get. Every other pre-download failure, such as the Traefik image,
the apt lists or the Docker packages, still aborts the whole update before any
change. get.docker.com is no longer run; a host without apt-get skips Docker
instead.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
…gh macOS

The WSL launcher forwarded fewer settings than the macOS launcher, so
DOKPLOY_BACKUP_DIR, DOKPLOY_HEALTH_TIMEOUT, DOKPLOY_HEALTH_INTERVAL,
DOKPLOY_TRAEFIK_SETTLE and AGENTHITS_PULL_RETRY_DELAY set on Windows never
reached the installer. They are added to $PassthroughVars. The macOS list
already had every setting. A test keeps the two lists equal.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
When a run adds a Docker apt repository and a later Docker step fails, the key
and source file that this run wrote are removed. The steps covered are the key
download, the candidate check after the update, the download of the rollback
packages, and the download of the new packages. The first three skip only
Docker, and the other components still run. A failed download of the new
packages still aborts the update, now without the repository left behind.

A key or source file that existed before the run is never removed, and an
existing key is no longer overwritten.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
…ed or the update aborts

The timestamped docker-rollback directory is created before the new packages
are downloaded. When Docker is skipped, or the update aborts before any change,
that directory is removed, but only if this run created it: it is made with a
plain mkdir, so an existing directory is never adopted or removed. The apt
package lists are left as they are, since they are a cache.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Renaming the running Traefik fails on swarm hosts where it is attached to
several networks ("cannot create entry in table endpoint_table ... already
exists"). The swap no longer renames anything, on the remote server path or
in the panel host installer.

The old container is stopped and removed with docker rm (no force). The new
one is created with --restart no, connected to dokploy-network and to the
captured networks (bridge excluded), and started. After the settle time it
must report running with no restarts, and only then is its restart policy
raised to always.

A failure after the old container was stopped removes whatever carries the
name, recreates the old image with the same flags and networks, starts it and
checks it, then exits 1 naming the step that failed. A leftover
dokploy-traefik-previous from earlier runs is removed first.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
…ll script does not download

The steps ran bash -c "$(curl ...)". When the download failed, the command
substitution came out empty, bash ran nothing, and the update reported
success. Each install script is now downloaded to a file with curl -fsSL,
checked for content and syntax, and only then run; any failure exits non-zero.
These steps do not touch running services, so they need no pre-download.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The install guide still described the swap as renaming the running Traefik
container and gave a manual rollback that used docker rename. It now describes
stop, remove, create with --restart no, connect, start, settle check, and then
--restart always, and gives a manual rollback that recreates the container from
the previous image.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
…tall script does not download

The setup ran bash -c "$(curl -fsSL ...)". When the download failed, the
command substitution came out empty, bash ran nothing, and the setup went on
to report Nixpacks or Railpack as installed. Each install script is now
downloaded to a file with curl -fsSL, checked for content and syntax, and only
then run with the pinned version in its environment; any failure stops the
setup with a non-zero exit.

Tests check the generated setup script, and run the Nixpacks, Railpack, RClone
and Buildpacks steps with a failing download and a working one.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
… script does not download

install_toolchain ran bash -c "$(curl -fsSL ...)" for Nixpacks and Railpack, so
a failed download ran nothing and the install went on. Both scripts now go
through one helper that downloads to a file, checks it, runs it with the
pinned version in its environment, and exits non-zero on any failure.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
… the update

The Buildpacks step pipes curl into tar and the RClone step downloads with
curl -fsSL before unzip. Both already exit non-zero when the download fails,
because tar rejects empty and HTML input and set -e stops the script. These
run tests keep it that way.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
agentHits and others added 3 commits October 8, 2026 11:15
…k starts it

A Docker rollback starts dokploy-traefik again. If the Traefik swap had left its
restart policy at "no", a daemon restart during the rollback would leave Traefik
down. The rollback now sets --restart always before it starts Traefik, on the
remote-server path and in the panel host installer.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
…ers and 120 s on the panel host

A failed Docker upgrade on a remote server waited 60 seconds before it rolled
back. The wait is now 30 one-second checks, and the rollback starts as soon as
they expire. On the panel host, the Docker checks use DOKPLOY_DOCKER_VERIFY_TIMEOUT
(120 seconds by default) instead of DOKPLOY_HEALTH_TIMEOUT, so the health timeout
of the panel update does not change how long Docker is given. The new variable
passes through the macOS and WSL launchers, and the install guide describes both
windows.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
…the first failed check

The swap waited the full settle time before checking the new Traefik container,
so a container that exited early still kept the ports dark until the wait ended.
Both paths now check it once a second over the settle time, with State.Running
and RestartCount, and roll back as soon as it is not running. The final check
after the settle time stays, and the restart policy is raised only after it.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
@agentHits
agentHits merged commit 003cc1b into AgentHits-Dev Oct 8, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant