Add Dockerfile, Compose stacks, and /healthz endpoint
Dockerfile is multi-stage:
- build: golang:1.25-bookworm, CGO_ENABLED=0 (modernc.org/sqlite
is pure-Go) + -trimpath + -ldflags "-s -w" so the resulting
binary is small and reproducible-ish.
- runtime: gcr.io/distroless/static:nonroot, ~2 MB. Runs as uid
65532. /data and /var/lib/librenotes are declared volumes so
per-tenant notes and the SQLite database survive container
restarts.
healthcheck subcommand: distroless static has no shell or
wget/curl, so /healthz is reachable but no client to call it. A
new "librenotes healthcheck" subcommand uses net/http to GET
$LIBRENOTES_HEALTHCHECK_URL (default 127.0.0.1:8080/healthz) and
exits non-zero on failure. Both compose files invoke it from the
HEALTHCHECK directive.
httpapi adds a tiny GET /healthz that returns {"status":"ok"}
(no DB ping yet — added when readiness probes need it).
docker-compose.yml: dev stack on :8080 with named volumes and a
LogMailer; everything via env vars, JWT secret defaulted to a
dev value.
docker-compose.prod.yml: layered overrides — pulls a registry
image, expects LIBRENOTES_* env, sets memory limits and JSON-
file log rotation.
Closes #25.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# Multi-stage Dockerfile for librenotes.
|
||||||
|
#
|
||||||
|
# Stage 1: build the static, CGO-free binary. modernc.org/sqlite
|
||||||
|
# is pure-Go so we don't need libc; this lets us drop the runtime
|
||||||
|
# image to gcr.io/distroless/static, which is ~2 MB.
|
||||||
|
#
|
||||||
|
# Stage 2: run as non-root (distroless static's "nonroot" user,
|
||||||
|
# uid 65532) with /data and /var/lib/librenotes mounted from
|
||||||
|
# named volumes so the database and per-tenant note files survive
|
||||||
|
# container restarts.
|
||||||
|
|
||||||
|
# ---- build ----
|
||||||
|
FROM golang:1.25-bookworm AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Cache module downloads on a separate layer.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG BUILDTIME
|
||||||
|
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
|
||||||
|
RUN go build \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags "-s -w \
|
||||||
|
-X git.librete.ch/public/librenotes/internal/notesium.gitversion=${VERSION} \
|
||||||
|
-X git.librete.ch/public/librenotes/internal/notesium.buildtime=${BUILDTIME}" \
|
||||||
|
-o /out/librenotes \
|
||||||
|
./cmd/librenotes
|
||||||
|
|
||||||
|
# ---- runtime ----
|
||||||
|
FROM gcr.io/distroless/static:nonroot AS runtime
|
||||||
|
COPY --from=build /out/librenotes /librenotes
|
||||||
|
USER nonroot:nonroot
|
||||||
|
EXPOSE 8080
|
||||||
|
VOLUME ["/data", "/var/lib/librenotes"]
|
||||||
|
ENV LIBRENOTES_ADDR=":8080" \
|
||||||
|
LIBRENOTES_DATA_DIR="/data" \
|
||||||
|
LIBRENOTES_DB="/var/lib/librenotes/librenotes.db"
|
||||||
|
ENTRYPOINT ["/librenotes", "serve"]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runHealthcheck pings /healthz on the local server and exits 0
|
||||||
|
// if the response is 2xx. Useful as a Docker HEALTHCHECK on
|
||||||
|
// distroless images that don't ship curl/wget.
|
||||||
|
//
|
||||||
|
// Defaults to http://127.0.0.1:8080/healthz; override with
|
||||||
|
// LIBRENOTES_HEALTHCHECK_URL.
|
||||||
|
func runHealthcheck(_ []string) error {
|
||||||
|
url := os.Getenv("LIBRENOTES_HEALTHCHECK_URL")
|
||||||
|
if url == "" {
|
||||||
|
url = "http://127.0.0.1:8080/healthz"
|
||||||
|
}
|
||||||
|
client := http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+14
-5
@@ -9,12 +9,21 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
args := os.Args[1:]
|
args := os.Args[1:]
|
||||||
if len(args) > 0 && args[0] == "serve" {
|
if len(args) > 0 {
|
||||||
if err := runServe(args[1:]); err != nil {
|
switch args[0] {
|
||||||
fmt.Fprintln(os.Stderr, "serve:", err)
|
case "serve":
|
||||||
os.Exit(1)
|
if err := runServe(args[1:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "serve:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "healthcheck":
|
||||||
|
if err := runHealthcheck(args[1:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "healthcheck:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
notesium.Run()
|
notesium.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# docker-compose.prod.yml — production overrides.
|
||||||
|
#
|
||||||
|
# Use:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
#
|
||||||
|
# Inputs (env or .env file):
|
||||||
|
# LIBRENOTES_IMAGE registry image, e.g. registry.librete.ch/librenotes:1.2.3
|
||||||
|
# LIBRENOTES_BASE_URL public origin, e.g. https://librenot.es
|
||||||
|
# LIBRENOTES_JWT_SECRET secrets manager value, NOT committed
|
||||||
|
# LIBRENOTES_SMTP_HOST real SMTP host
|
||||||
|
# LIBRENOTES_SMTP_PORT submission port (587 default)
|
||||||
|
# LIBRENOTES_SMTP_USER SMTP credential
|
||||||
|
# LIBRENOTES_SMTP_PASS SMTP credential
|
||||||
|
# LIBRENOTES_SMTP_FROM envelope sender (e.g. no-reply@librenot.es)
|
||||||
|
services:
|
||||||
|
librenotes:
|
||||||
|
image: ${LIBRENOTES_IMAGE}
|
||||||
|
build: !reset null
|
||||||
|
environment:
|
||||||
|
LIBRENOTES_BASE_URL: ${LIBRENOTES_BASE_URL}
|
||||||
|
LIBRENOTES_JWT_SECRET: ${LIBRENOTES_JWT_SECRET}
|
||||||
|
LIBRENOTES_SMTP_HOST: ${LIBRENOTES_SMTP_HOST}
|
||||||
|
LIBRENOTES_SMTP_PORT: ${LIBRENOTES_SMTP_PORT:-587}
|
||||||
|
LIBRENOTES_SMTP_USER: ${LIBRENOTES_SMTP_USER}
|
||||||
|
LIBRENOTES_SMTP_PASS: ${LIBRENOTES_SMTP_PASS}
|
||||||
|
LIBRENOTES_SMTP_FROM: ${LIBRENOTES_SMTP_FROM}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/librenotes", "healthcheck"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
reservations:
|
||||||
|
memory: 64M
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# docker-compose.yml — local development.
|
||||||
|
#
|
||||||
|
# Brings up librenotes on http://localhost:8080 with the magic-link
|
||||||
|
# mailer logging links to stdout (no SMTP needed). Data lives in
|
||||||
|
# named volumes so `docker compose down` does NOT wipe state.
|
||||||
|
# Use `docker compose down -v` for a clean slate.
|
||||||
|
services:
|
||||||
|
librenotes:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
VERSION: dev
|
||||||
|
image: librenotes:dev
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
LIBRENOTES_BASE_URL: "http://localhost:8080"
|
||||||
|
# JWT secret must be at least 32 bytes. Override per-host for
|
||||||
|
# any non-throwaway environment.
|
||||||
|
LIBRENOTES_JWT_SECRET: "dev-secret-32-bytes-of-keymaterial!!"
|
||||||
|
volumes:
|
||||||
|
- notes:/data
|
||||||
|
- state:/var/lib/librenotes
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/librenotes", "healthcheck"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
notes:
|
||||||
|
state:
|
||||||
@@ -22,6 +22,10 @@ type Server struct {
|
|||||||
func (s *Server) Routes() http.Handler {
|
func (s *Server) Routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
})
|
||||||
mux.HandleFunc("/auth/login", s.Auth.HandleLogin)
|
mux.HandleFunc("/auth/login", s.Auth.HandleLogin)
|
||||||
mux.HandleFunc("/auth/verify", s.Auth.HandleVerify)
|
mux.HandleFunc("/auth/verify", s.Auth.HandleVerify)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user