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
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -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"]
|
||||
31
cmd/librenotes/healthcheck.go
Normal file
31
cmd/librenotes/healthcheck.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -9,12 +9,21 @@ import (
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
if len(args) > 0 && args[0] == "serve" {
|
||||
if err := runServe(args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "serve:", err)
|
||||
os.Exit(1)
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "serve":
|
||||
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()
|
||||
}
|
||||
|
||||
43
docker-compose.prod.yml
Normal file
43
docker-compose.prod.yml
Normal file
@@ -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"
|
||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -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 {
|
||||
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/verify", s.Auth.HandleVerify)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user