From f43c3c53a9e8f404bd3dc6cee2ffd16a2478cca7 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Thu, 5 Feb 2026 20:50:36 +0100 Subject: [PATCH] feat: add healthcheck support for container image - Add 'health' subcommand to ackify binary for Docker HEALTHCHECK - Add HEALTHCHECK directive to Dockerfile - Add healthcheck configuration to all compose files - Supports custom port via ACKIFY_LISTEN_ADDR Closes #21 --- Dockerfile | 3 +++ backend/cmd/community/main.go | 45 +++++++++++++++++++++++++++++++++++ compose.e2e.yml | 6 +++++ compose.yml | 6 +++++ install/compose.yml.template | 8 ++++++- 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b9d4da9..2b6f6dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,4 +87,7 @@ ENV ACKIFY_LOCALES_DIR=/app/locales EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/app/ackify", "health"] + ENTRYPOINT ["/app/ackify"] diff --git a/backend/cmd/community/main.go b/backend/cmd/community/main.go index 1b118cf..03ec962 100644 --- a/backend/cmd/community/main.go +++ b/backend/cmd/community/main.go @@ -4,6 +4,7 @@ import ( "context" "embed" "errors" + "fmt" "log" "net/http" "os" @@ -29,6 +30,11 @@ var ( var frontend embed.FS func main() { + // Handle health check subcommand for Docker HEALTHCHECK + if len(os.Args) > 1 && os.Args[1] == "health" { + os.Exit(runHealthCheck()) + } + ctx := context.Background() cfg, err := config.Load() @@ -86,3 +92,42 @@ func main() { log.Println("Community Edition server exited") } + +// runHealthCheck performs a health check against the local server. +// Returns 0 on success, 1 on failure. +func runHealthCheck() int { + addr := os.Getenv("ACKIFY_LISTEN_ADDR") + if addr == "" { + addr = ":8080" + } + + // Build health URL (handle both ":8080" and "0.0.0.0:8080" formats) + host := "localhost" + port := addr + if addr[0] != ':' { + // Format is "host:port", extract port + for i := len(addr) - 1; i >= 0; i-- { + if addr[i] == ':' { + port = addr[i:] + break + } + } + } + + url := fmt.Sprintf("http://%s%s/api/v1/health", host, port) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(url) + if err != nil { + fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err) + return 1 + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "Health check failed: status %d\n", resp.StatusCode) + return 1 + } + + return 0 +} diff --git a/compose.e2e.yml b/compose.e2e.yml index 63729c1..2b10f18 100644 --- a/compose.e2e.yml +++ b/compose.e2e.yml @@ -65,6 +65,12 @@ services: condition: service_healthy ports: - "8080:8080" + healthcheck: + test: ["CMD", "/app/ackify", "health"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 ackify-db: image: postgres:16-alpine diff --git a/compose.yml b/compose.yml index af06bf8..808a65e 100644 --- a/compose.yml +++ b/compose.yml @@ -68,6 +68,12 @@ services: - internal ports: - "8080:8080" + healthcheck: + test: ["CMD", "/app/ackify", "health"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 ackify-db: image: postgres:16-alpine diff --git a/install/compose.yml.template b/install/compose.yml.template index 519ca95..597d0a9 100644 --- a/install/compose.yml.template +++ b/install/compose.yml.template @@ -1,4 +1,4 @@ -## SPDX-License-Identifier: AGPL-3.0-or-later + ## SPDX-License-Identifier: AGPL-3.0-or-later name: ackify-ce services: @@ -104,6 +104,12 @@ services: ports: - "8080:8080" #END:ports + healthcheck: + test: ["CMD", "/app/ackify", "health"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 ackify-db: image: postgres:16-alpine