Run keepalived (VRRP failover / high availability) in a container. Bring your own keepalived.conf.
keepalived implements VRRP, so two or more machines share a virtual IP with automatic failover: one node owns the VIP, and another takes over within seconds if it dies.
This image is a minimal Alpine wrapper around the upstream keepalived package. There's no entrypoint magic, no env-var-to-config translation, no bundled scripts: you mount your own keepalived.conf and any track / notify scripts it references, and keepalived runs as PID 1.
- Multi-arch —
linux/amd64andlinux/arm64 - Tiny — Alpine + the
keepalivedbinary, nothing else - No bundled scripts — bring your own track / notify helpers via the bind mount
- Healthcheck — built-in
pidof keepalivedprocess check
- Generic upstream-only — no custom track scripts baked in. The image is reusable across any VRRP topology without inheriting someone else's check logic
- Bind-mount only — single read-only
:romount of/etc/keepalivedkeeps the container's writable surface zero - Host networking — VRRP uses multicast (224.0.0.18 for IPv4, ff02::12 for IPv6) and needs the host's network namespace to advertise on a real LAN interface
- No PID 1 wrapper —
keepalived --dont-forkruns as PID 1 directly, so SIGTERM fromdocker stopreaches it instantly without a wrapper layer
Available from both ghcr.io/cplieger/docker-keepalived and docker.io/cplieger/docker-keepalived — identical images and tags.
services:
keepalived:
image: ghcr.io/cplieger/docker-keepalived:latest
container_name: keepalived
restart: unless-stopped
# VRRP needs host networking + raw socket / admin caps.
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
# Mount your keepalived.conf — and any track / notify scripts it
# references. A scripts/ subdir alongside keepalived.conf is the
# natural layout, with paths like /etc/keepalived/scripts/<name>.sh.
volumes:
- ./keepalived:/etc/keepalived:roMinimal keepalived.conf (active node, priority 150):
global_defs {
router_id MY_PRIMARY
script_user root
enable_script_security
}
vrrp_script chk_app {
script "/etc/keepalived/scripts/check_app.sh"
interval 5
timeout 3
fall 2
rise 2
}
vrrp_instance VI_1 {
state MASTER
interface eth0
virtual_router_id 51
priority 150
advert_int 1
authentication {
auth_type PASS
auth_pass changeme
}
virtual_ipaddress {
192.168.1.250/24
}
track_script {
chk_app
}
}The backup node uses the same config with state BACKUP, priority 100, and the same auth_pass.
If your keepalived.conf sets enable_script_security in global_defs (recommended), keepalived refuses to execute scripts whose path inside the container has any non-root-writable component. Track scripts get silently disabled with this log line:
Unsafe permissions found for script '/etc/keepalived/scripts/check_app.sh' - disabling.
Disabling track script chk_app due to insecure
Inside the container, /etc/keepalived mirrors the host bind-mount source's ownership and mode. So you need to ensure:
- The host directory you mount at
/etc/keepalivedis owned byroot:root - The directory is not group-writable or world-writable (mode 755 is fine; 770 is not because group-writable counts as "writable by non-root")
- Same for any
scripts/subdirectory — must beroot:rootand not group-writable - Each track / notify script file must also be root-owned and not group/world-writable — keepalived applies the same check to the script file, not just its parent directories
A common gotcha: many host bind-mount layouts inherit non-root ownership from a parent dir. Fix on each server with:
chown -R root:root /path/to/keepalived
chmod 755 /path/to/keepalived /path/to/keepalived/scripts
# script files must also be non-group/world-writable (keepalived checks the file too)
chmod 644 /path/to/keepalived/keepalived.conf
find /path/to/keepalived/scripts -type f -exec chmod 755 {} +If you don't use enable_script_security, none of this applies — but you should use it.
| Mount | Description |
|---|---|
/etc/keepalived |
Your keepalived.conf and any track / notify scripts it references. Mount read-only. Must be root-owned and not group/world-writable if enable_script_security is set. |
| Capability | Why needed |
|---|---|
NET_ADMIN |
Adding / removing the virtual IP, socket option configuration |
NET_RAW |
Constructing VRRP packets (raw sockets) and ICMP probes |
| Setting | Value | Reason |
|---|---|---|
network_mode |
host |
VRRP advertisements use multicast on the LAN segment; container networking would isolate them |
VRRP multicast addresses (RFC 5798): 224.0.0.18 (IPv4), ff02::12 (IPv6). NET_BROADCAST is not required.
The built-in healthcheck verifies the keepalived process is running:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \
CMD pidof keepalived >/dev/null || exit 1This catches the "process crashed" failure mode but not "VRRP is stuck": for that, watch the notify script logs and the VRRP_Script success/failure log lines, or scrape keepalived's stats interface (SIGUSR2 triggers a stats dump to /tmp/keepalived.stats).
The image runs keepalived --dont-fork --log-console --log-detail, so all VRRP state transitions, track-script success/failure lines, and the Unsafe permissions found ... - disabling warning are written to the container's stdout/stderr and appear in docker logs keepalived (and any log shipper scraping it). This is where you watch for a stuck VRRP or a silently-disabled track script that the pidof healthcheck cannot see.
keepalived logs VRRP state transitions and config events to its container log (the same stream the Healthcheck section describes shipping to a log collector). Ship it to Loki and evaluate the rules in alerts.yaml with Loki's ruler; firing alerts deliver through your Alertmanager like any Prometheus alert. They cover:
| Alert | Fires when | Severity |
|---|---|---|
KeepalivedTrackScriptFailed |
a VRRP track script reports failed (failover imminent) | critical |
KeepalivedFaultState |
a VRRP instance enters FAULT state and drops out of the election | critical |
KeepalivedConfigError |
keepalived logs an unknown-keyword or parse error after a (re)deploy | warning |
Thresholds and the severity labels are starting points; adjust the container and label selectors (such as the hostname grouping) to match your log collector, and route by whatever labels your Alertmanager uses.
To apply a config change without a container restart (no VIP transition):
docker kill -s HUP keepalivedkeepalived re-reads keepalived.conf and applies any changes — track scripts are re-evaluated, instance config is reapplied. VRRP state is preserved for unchanged instances; only changed instances briefly renegotiate.
| Tool | Result |
|---|---|
| hadolint | Clean |
| gitleaks | No secrets detected |
| trivy | Inherits the Alpine base image scan |
The image is published with cosign signatures and SBOM attestations. Verify a pull:
cosign verify ghcr.io/cplieger/docker-keepalived:latest \
--certificate-identity-regexp "https://github.com/cplieger/docker-keepalived/.github/workflows/.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.comIf you advertise IPv6 prefixes on the LAN with radvd, keepalived can manage the IPv6 VIP and radvd can use it as the source address for Router Advertisements via AdvRASrcAddress. See docker-radvd for a sibling container that's already wired up for this pattern.
Dependencies are updated automatically via Renovate. The base image is pinned by SHA digest; the keepalived apk package is installed unpinned so it tracks the digest-pinned base (pinning the apk revision strands the build when Alpine bumps releases and drops the old revision):
- Alpine Linux — base image (Docker Hub)
- keepalived — Alpine community package (upstream)
Because the keepalived apk package is unpinned, its CVE currency in the published image is bounded by rebuild cadence rather than by keepalived releases: Renovate can bump the SHA-pinned Alpine base digest, but it cannot see an unpinned apk revision, and there is no independent keepalived-version trigger. A keepalived fix that lands in the Alpine repo without a coincident base-digest change only reaches :latest on the next rebuild. Operators who need faster patch response should rebuild / pull on a cadence or run their own trivy scan of the :latest image.
This project packages keepalived into a container image. All credit for the core functionality goes to the upstream maintainers — Alexandre Cassen and the keepalived community.
Issues and pull requests are welcome. Please open an issue first for larger changes so the approach can be discussed before implementation.
This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
This project is licensed under the GNU General Public License v3.0.