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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.git
|
||||||
|
prompt-sync
|
||||||
|
prompt-sync-*
|
||||||
|
*.md
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user