feat: foundation Go module, HTTP server, Docker packaging

- Go module (github.com/elfoundation/hatch) with go 1.25
- cmd/hatch: HTTP server with GET /healthz returning 200 OK
- Multi-stage Dockerfile producing static binary from scratch
- docker-compose with hatch + optional Caddy reverse proxy
- CI placeholder with Go test/lint stubs
- .gitignore, .dockerignore, .env.example

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
SoftwareEngineer 2026-06-22 14:31:27 +02:00 committed by CTO
parent 2fa4184a32
commit eed408bb19
11 changed files with 308 additions and 15 deletions

37
.dockerignore Normal file
View File

@ -0,0 +1,37 @@
# Git
.git
.gitignore
.gitattributes
# Docs (not needed at runtime)
docs/
# Editor config
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Environment (keep .env.example)
.env
.env.local
.env.*.local
# Docker (avoid recursive copy)
Dockerfile
docker-compose.yml
.dockerignore
# Node artifacts (from pre-migration scaffold, will be removed by ELF-15)
node_modules/
pnpm-lock.yaml
package.json
apps/
# Test / build caches
*.test
coverage/

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
# Public hostname for HTTPS (Caddy will obtain a Let's Encrypt cert automatically).
# Leave unset or set to "localhost" for local dev (self-signed cert).
HATCH_HOSTNAME=hatch.example.com
# Base URL for SSE and self-referencing links within the UI.
HATCH_BASE_URL=https://hatch.example.com

View File

@ -7,6 +7,40 @@ on:
branches: [main]
jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Vet
run: go vet ./...
- name: Test
run: go test ./... -v -race
- name: Build
run: CGO_ENABLED=0 go build -o /dev/null ./cmd/hatch
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t hatch:ci .
- name: Smoke test the Docker image
run: |
docker run --rm -d -p 8080:8080 --name hatch-ci hatch:ci
sleep 2
curl -fsS http://localhost:8080/healthz | grep -q 'ok'
docker stop hatch-ci
lint-docs:
runs-on: ubuntu-latest
steps:
@ -16,18 +50,3 @@ jobs:
with:
globs: '**/*.md'
continue-on-error: true
# TODO: Enable once we have a TypeScript project
# lint-and-test:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: pnpm/action-setup@v4
# - uses: actions/setup-node@v4
# with:
# node-version: 22
# cache: 'pnpm'
# - run: pnpm install --frozen-lockfile
# - run: pnpm lint
# - run: pnpm typecheck
# - run: pnpm test

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
go.work
# Build
/bin
/dist
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store

19
Caddyfile Normal file
View File

@ -0,0 +1,19 @@
{$HATCH_HOSTNAME:localhost} {
reverse_proxy hatch:8080
# Log requests for debugging
log {
output stdout
format json
}
# Security headers
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
}
# TLS: auto via Let's Encrypt when HATCH_HOSTNAME is a real domain.
# Falls back to self-signed for localhost / internal-only names.
}

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
# syntax=docker/dockerfile:1
# ---- build stage ----
FROM golang:1.25-alpine AS build
WORKDIR /src
# Cache module downloads before copying source (layer caching).
COPY go.mod ./
RUN go mod download
COPY . ./
# Build a fully static binary (CGO disabled, no libc dependency).
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/hatch ./cmd/hatch
# ---- run stage ----
FROM scratch
COPY --from=build /bin/hatch /bin/hatch
EXPOSE 8080
ENTRYPOINT ["/bin/hatch"]

View File

@ -25,6 +25,43 @@ El Foundation builds institutions that outlast their founders. We create technol
└── scripts/ # Automation and utility scripts
```
## Hatch — Deploy in one command
Hatch is a self-hostable HTTP request inspector + mocker. Ship it to any VPS with Docker.
### Quick start (local dev, no HTTPS)
```bash
docker compose up --build
# Hatch UI: http://localhost:8080
# Capture endpoint: http://localhost:8080/{endpoint-id}
```
### Production (with HTTPS via Caddy)
```bash
# Set your domain name
cp .env.example .env
# Edit HATCH_HOSTNAME in .env to your real domain
# Start Hatch + Caddy (auto-issues Let's Encrypt cert)
docker compose --profile with-caddy up -d --build
# Hatch UI: https://{your-domain}
# Capture endpoint: https://{your-domain}/{endpoint-id}
```
### Architecture
```
Internet → :443 (Caddy) → hatch:8080 (Go binary, internal network)
├─ Auto TLS (Let's Encrypt, or self-signed for localhost)
├─ Reverse proxy with security headers
└─ JSON access logs to stdout
```
Caddy terminates TLS and reverse-proxies to the Hatch Go binary. The Hatch container only listens on `127.0.0.1:8080` — it's never directly exposed to the internet.
## Technology Stack
See [docs/engineering/tech-stack.md](docs/engineering/tech-stack.md) for current choices and rationale.

36
cmd/hatch/main.go Normal file
View File

@ -0,0 +1,36 @@
// Command hatch is the Hatch HTTP request inspector + mocker server.
//
// Hatch captures, inspects, and mocks HTTP requests. It ships as a single
// static binary — one command on a VPS and your payloads never leave your
// network.
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
addr := fmt.Sprintf(":%s", port)
log.Printf("hatch starting on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("hatch server error: %v", err)
}
}
// healthz returns 200 OK with body "ok" for liveness probes.
func healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
}

58
cmd/hatch/main_test.go Normal file
View File

@ -0,0 +1,58 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHealthz(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
healthz(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("failed to read response body: %v", err)
}
got := strings.TrimSpace(string(body))
if got != "ok" {
t.Fatalf("expected body 'ok', got '%s'", got)
}
}
func TestHealthzSmoke(t *testing.T) {
// Smoke test: boot the server on a random port, hit /healthz, verify 200 + "ok".
srv := httptest.NewServer(http.HandlerFunc(healthz))
defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz")
if err != nil {
t.Fatalf("failed to GET /healthz: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response body: %v", err)
}
got := strings.TrimSpace(string(body))
if got != "ok" {
t.Fatalf("expected body 'ok', got '%s'", got)
}
}

33
docker-compose.yml Normal file
View File

@ -0,0 +1,33 @@
services:
hatch:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
volumes:
- hatch-data:/data
environment:
- HATCH_PORT=8080
- HATCH_DB_PATH=/data/hatch.db
- HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost}
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
environment:
- HATCH_HOSTNAME=${HATCH_HOSTNAME:-localhost}
# Caddy runs even without a real hostname (self-signed)
profiles:
- with-caddy
volumes:
hatch-data:
caddy-data:
caddy-config:

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/elfoundation/hatch
go 1.25