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
+6 -3
View File
@@ -42,9 +42,12 @@ clicking an item copies it back to the clipboard.
code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`, code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`,
`sync.version`), pushes the whole store as JSON, and polls every 5s. `sync.version`), pushes the whole store as JSON, and polls every 5s.
- `Server/main.go` — the Go sync backend (single file, `net/http` + `lib/pq`). - `Server/main.go` — the Go sync backend (single file, `net/http` + `lib/pq`).
Also ships `Server/README.md` (self-hosting) and `Server/prompt-sync.service` Also ships `Server/Dockerfile` + `Server/docker-compose.yml` (Postgres +
(systemd unit). Logs one line per request, creates its schema on startup, and server), `Server/README.md` (self-hosting), and `Server/prompt-sync.service`
shuts down gracefully on SIGTERM so a redeploy cannot cut off a push. (systemd unit). Logs one line per request, creates its schema on startup,
shuts down gracefully on SIGTERM so a redeploy cannot cut off a push, and
supports `-healthcheck` (pings local `/healthz`) so the distroless image
needs no shell for its Docker `HEALTHCHECK`.
## Sync protocol ## Sync protocol
+3
View File
@@ -58,6 +58,9 @@ PROMPT_SYNC_DATABASE_URL="postgres://localhost:5432/prompt_sync?sslmode=disable"
go run . # listens on :8080 go run . # listens on :8080
``` ```
Or run it with Docker (`POSTGRES_PASSWORD=… docker compose up -d`) — see
[Server/README.md](Server/README.md) for that and for deploying to a VPS.
Then in the panel: click the sync icon, set the server URL (e.g. Then in the panel: click the sync icon, set the server URL (e.g.
`http://192.168.1.10:8080` or an HTTPS host), and either **Generate Code** on `http://192.168.1.10:8080` or an HTTPS host), and either **Generate Code** on
the first Mac or paste the code into **Join** on the other. the first Mac or paste the code into **Join** on the other.
+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 `METHOD PATH STATUS DURATION`; SIGTERM/SIGINT shut down gracefully so an
in-flight push is not cut off mid-write. 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 ## Deploy to a Linux host
```bash ```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"
"os/signal" "os/signal"
"regexp" "regexp"
"strings"
"syscall" "syscall"
"time" "time"
@@ -48,6 +49,13 @@ type Server struct {
} }
func main() { 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") dsn := os.Getenv("PROMPT_SYNC_DATABASE_URL")
if dsn == "" { if dsn == "" {
dsn = "postgres://localhost:5432/prompt_sync?sslmode=disable" 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 // 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. // self-hosted deployment can be debugged from journalctl or docker logs.
func withLogging(next http.Handler) http.Handler { func withLogging(next http.Handler) http.Handler {