Add a Dockerfile so the sync server is one command to deploy

Self-hosting meant building the binary by hand and wiring Postgres up
yourself. The image is a static binary on distroless with a built-in
healthcheck flag, and docker-compose brings up Postgres alongside it.

The image itself is unbuilt here: no container runtime is available in
this environment, so only the static Linux build and the healthcheck
flag have been exercised.

💘 Generated with Crush

Assisted-by: Crush:kimi-k3
This commit is contained in:
2026-10-08 10:23:54 +08:00
parent f3e6fc8fa6
commit cbdf5538aa
7 changed files with 132 additions and 3 deletions
+4
View File
@@ -0,0 +1,4 @@
.git
prompt-sync
prompt-sync-*
*.md
+27
View File
@@ -0,0 +1,27 @@
# Build stage: the server is pure Go (lib/pq is pure Go), so CGO is off and the
# result is a static binary that runs on a shell-less base image.
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /prompt-sync .
# Runtime stage: distroless has no shell, so the binary does its own
# healthcheck (see -healthcheck in main.go). Runs as non-root.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /prompt-sync /prompt-sync
ENV PROMPT_SYNC_ADDR=:8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/prompt-sync", "-healthcheck"]
ENTRYPOINT ["/prompt-sync"]
+24
View File
@@ -28,6 +28,30 @@ The schema is created on startup. Every request is logged as
`METHOD PATH STATUS DURATION`; SIGTERM/SIGINT shut down gracefully so an
in-flight push is not cut off mid-write.
## Run with Docker
`docker-compose.yml` starts Postgres and the server together:
```bash
cd Server
POSTGRES_PASSWORD=$(openssl rand -hex 16) docker compose up -d
docker compose logs -f server # request log
```
Or build the image on its own and point it at an existing Postgres:
```bash
docker build -t prompt-sync Server/
docker run -d --name prompt-sync -p 8080:8080 \
-e PROMPT_SYNC_DATABASE_URL="postgres://user:pass@host:5432/prompt_sync?sslmode=disable" \
prompt-sync
```
The image is a static binary on `distroless` (non-root, no shell, ~6 MB binary),
so its `HEALTHCHECK` calls `prompt-sync -healthcheck`, which pings the local
`/healthz` and exits non-zero on failure — no curl needed in the image. That is
what `docker ps` reports as the container health.
## Deploy to a Linux host
```bash
+37
View File
@@ -0,0 +1,37 @@
# Postgres + the sync server. `docker compose up -d` is the whole deploy:
#
# POSTGRES_PASSWORD=$(openssl rand -hex 16) docker compose up -d
#
# Then point the app at http://<host>:8080. The data lives in the named volume,
# so `docker compose down` keeps it and `down -v` wipes it.
services:
db:
image: postgres:18-alpine
environment:
POSTGRES_DB: prompt_sync
POSTGRES_USER: prompt
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- prompt-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U prompt -d prompt_sync"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
server:
build: .
environment:
PROMPT_SYNC_ADDR: ":8080"
PROMPT_SYNC_DATABASE_URL: postgres://prompt:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/prompt_sync?sslmode=disable
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
prompt-db:
+31
View File
@@ -27,6 +27,7 @@ import (
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
@@ -48,6 +49,13 @@ type Server struct {
}
func main() {
// -healthcheck pings the local /healthz and exits non-zero on failure. It
// exists so the container image can run on a shell-less base (distroless),
// where there is no wget or curl for a HEALTHCHECK to use.
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(healthcheck())
}
dsn := os.Getenv("PROMPT_SYNC_DATABASE_URL")
if dsn == "" {
dsn = "postgres://localhost:5432/prompt_sync?sslmode=disable"
@@ -103,6 +111,29 @@ func main() {
}
}
// healthcheck returns 0 when the local /healthz answers 200.
func healthcheck() int {
addr := os.Getenv("PROMPT_SYNC_ADDR")
if addr == "" {
addr = ":8080"
}
if strings.HasPrefix(addr, ":") {
addr = "127.0.0.1" + addr
}
client := &http.Client{Timeout: 3 * time.Second}
response, err := client.Get("http://" + addr + "/healthz")
if err != nil {
log.Printf("healthcheck: %v", err)
return 1
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
log.Printf("healthcheck: unexpected status %d", response.StatusCode)
return 1
}
return 0
}
// withLogging keeps a one-line trail of method, path, status and duration so a
// self-hosted deployment can be debugged from journalctl or docker logs.
func withLogging(next http.Handler) http.Handler {