Compare commits

..

4 Commits

Author SHA1 Message Date
Admin
cb9598a786 chore: migrate Docker builds to docker buildx bake
Some checks failed
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 2m10s
Release / Docker (push) Failing after 3m43s
Release / Deploy to prod (push) Has been skipped
Release / Gitea Release (push) Has been skipped
Replace 5 sequential build-push-action steps with a single docker/bake-action
call backed by docker-bake.hcl. BuildKit now builds all images in parallel:
backend/runner/pocketbase share one Go builder stage (compiled once), while
caddy and ui build concurrently alongside the Go targets.

Workflow YAML goes from ~130 lines of build steps to ~35. Adding a new image
now only requires a new target block in docker-bake.hcl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 19:09:27 +05:00
Admin
fc73756308 fix: run pocketbase as root + add healthcheck start_period
Some checks failed
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 1m59s
Release / Docker (push) Successful in 7m49s
Release / Deploy to prod (push) Failing after 33s
Release / Gitea Release (push) Successful in 29s
- Remove non-root user from pocketbase Docker image; the existing pb_data
  volume was created by the previous root-running image so files are owned
  by root — running as a non-root appuser caused an immediate permission
  error and container exit
- Increase healthcheck retries to 10 and add start_period=30s so migrations
  have time to run on first boot before liveness checks begin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 19:00:21 +05:00
Admin
3f436877ee fix: pocketbase healthcheck (add wget) + saveIfAbsent infinite recursion
Some checks failed
Release / Test backend (push) Successful in 1m6s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Successful in 8m19s
Release / Deploy to prod (push) Failing after 36s
Release / Gitea Release (push) Successful in 22s
- Add wget to pocketbase Alpine image so the docker-compose healthcheck
  (wget http://localhost:8090/api/health) can actually run
- Fix saveIfAbsent calling itself instead of app.Save(c) — was an
  infinite recursion that would stack-overflow on a fresh install

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:05:02 +05:00
Admin
812028e50d ci: add pocketbase image build + automated prod deploy step
Some checks failed
Release / Test backend (push) Successful in 5m39s
Release / Check ui (push) Successful in 2m1s
Release / Docker (push) Successful in 7m46s
Release / Deploy to prod (push) Failing after 1m53s
Release / Gitea Release (push) Successful in 35s
release.yaml:
  - Build and push kalekber/libnovel-pocketbase image on every release tag
  - Add deploy job (runs after docker): copies docker-compose.yml from the
    tagged commit to /opt/libnovel on prod, pulls new images, restarts
    changed services with --remove-orphans (cleans up removed pb-init)

ci.yaml:
  - Validate cmd/pocketbase builds on every branch push

Required new Gitea secrets: PROD_HOST, PROD_USER, PROD_SSH_KEY,
PROD_SSH_KNOWN_HOSTS (see deploy job comments for instructions).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:06:27 +05:00
6 changed files with 207 additions and 87 deletions

View File

@@ -41,6 +41,9 @@ jobs:
- name: Build healthcheck
run: go build -o /dev/null ./cmd/healthcheck
- name: Build pocketbase
run: go build -o /dev/null ./cmd/pocketbase
- name: Run tests
run: go test -short -race -count=1 -timeout=60s ./...

View File

@@ -147,10 +147,14 @@ jobs:
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: ui
# ── docker: all images in one job (single login) ──────────────────────────────
# backend, runner, ui, and caddy are built sequentially in one job so Docker
# Hub only needs to be authenticated once. This also eliminates 3 redundant
# checkout + setup-buildx + scheduler round-trips compared to separate jobs.
# ── docker: build + push all images via docker bake ──────────────────────────
# docker-bake.hcl defines all five targets. BuildKit builds them with maximum
# parallelism: backend/runner/pocketbase share the Go builder stage (compiled
# once), while caddy and ui build concurrently alongside the Go targets.
#
# Metadata (tags + labels) is generated per-image by docker/metadata-action
# (bake-target mode), then merged into a single bake call via the bake-action
# targets input — one Docker Hub login, one buildx invocation.
docker:
name: Docker
runs-on: ubuntu-latest
@@ -160,65 +164,13 @@ jobs:
- uses: docker/setup-buildx-action@v3
# Single login — credential is written to ~/.docker/config.json and
# reused by all subsequent build-push-action steps in this job.
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
# ── backend ──────────────────────────────────────────────────────────────
- name: Docker meta / backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-backend
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push / backend
uses: docker/build-push-action@v6
with:
context: backend
target: backend
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
build-args: |
VERSION=${{ steps.meta-backend.outputs.version }}
COMMIT=${{ gitea.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-backend:latest
cache-to: type=inline
# ── runner ───────────────────────────────────────────────────────────────
- name: Docker meta / runner
id: meta-runner
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-runner
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push / runner
uses: docker/build-push-action@v6
with:
context: backend
target: runner
push: true
tags: ${{ steps.meta-runner.outputs.tags }}
labels: ${{ steps.meta-runner.outputs.labels }}
build-args: |
VERSION=${{ steps.meta-runner.outputs.version }}
COMMIT=${{ gitea.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-runner:latest
cache-to: type=inline
# ── ui ───────────────────────────────────────────────────────────────────
# ── Prepare ui build artifact ─────────────────────────────────────────────
- name: Download ui build artifacts
uses: actions/download-artifact@v3
with:
@@ -230,51 +182,121 @@ jobs:
grep -v '^build$' ui/.dockerignore > ui/.dockerignore.tmp
mv ui/.dockerignore.tmp ui/.dockerignore
# ── Generate per-image tags + labels (bake-target mode) ───────────────────
- name: Docker meta / backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-backend
bake-target: backend
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Docker meta / runner
id: meta-runner
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-runner
bake-target: runner
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Docker meta / pocketbase
id: meta-pocketbase
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-pocketbase
bake-target: pocketbase
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Docker meta / ui
id: meta-ui
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
bake-target: ui
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push / ui
uses: docker/build-push-action@v6
with:
context: ui
push: true
tags: ${{ steps.meta-ui.outputs.tags }}
labels: ${{ steps.meta-ui.outputs.labels }}
build-args: |
BUILD_VERSION=${{ steps.meta-ui.outputs.version }}
BUILD_COMMIT=${{ gitea.sha }}
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
PREBUILT=1
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
cache-to: type=inline
# ── caddy ────────────────────────────────────────────────────────────────
- name: Docker meta / caddy
id: meta-caddy
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-caddy
bake-target: caddy
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push / caddy
uses: docker/build-push-action@v6
# ── Build + push all images in parallel ───────────────────────────────────
- name: Build and push all images
uses: docker/bake-action@v6
with:
context: caddy
push: true
tags: ${{ steps.meta-caddy.outputs.tags }}
labels: ${{ steps.meta-caddy.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-caddy:latest
cache-to: type=inline
files: |
docker-bake.hcl
${{ steps.meta-backend.outputs.bake-file }}
${{ steps.meta-runner.outputs.bake-file }}
${{ steps.meta-pocketbase.outputs.bake-file }}
${{ steps.meta-ui.outputs.bake-file }}
${{ steps.meta-caddy.outputs.bake-file }}
set: |
backend.args.VERSION=${{ steps.meta-backend.outputs.version }}
backend.args.COMMIT=${{ gitea.sha }}
runner.args.VERSION=${{ steps.meta-runner.outputs.version }}
runner.args.COMMIT=${{ gitea.sha }}
ui.args.BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
*.output=type=image,push=true
# ── deploy: sync docker-compose.yml + restart prod ───────────────────────────
# Runs after all images are pushed to Docker Hub.
# Copies the compose file from the tagged commit to the server, pulls the new
# images, and restarts only the services whose image or config changed.
# --remove-orphans cleans up containers no longer defined in the compose file
# (e.g. the now-removed pb-init container).
#
# Required Gitea secrets:
# PROD_HOST — prod server IP or hostname
# PROD_USER — SSH login user (typically root)
# PROD_SSH_KEY — private key whose public half is in authorized_keys
# PROD_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H <PROD_HOST>
deploy:
name: Deploy to prod
runs-on: ubuntu-latest
needs: [docker]
steps:
- uses: actions/checkout@v4
- name: Install SSH key
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "${{ secrets.PROD_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
- name: Copy docker-compose.yml to prod
run: |
scp -i ~/.ssh/deploy_key \
docker-compose.yml \
"${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}:/opt/libnovel/docker-compose.yml"
- name: Pull new images and restart changed services
run: |
ssh -i ~/.ssh/deploy_key \
"${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}" \
'set -euo pipefail
cd /opt/libnovel
doppler run -- docker compose pull
doppler run -- docker compose up -d --remove-orphans'
# ── Gitea release ─────────────────────────────────────────────────────────────
release:

View File

@@ -48,13 +48,11 @@ ENTRYPOINT ["/backend"]
# On every `serve` startup it applies any pending migrations automatically.
# Data is stored in /pb_data (mounted as a Docker volume in production).
FROM alpine:3.21 AS pocketbase
RUN apk add --no-cache ca-certificates && \
addgroup -S appgroup && adduser -S appuser -G appgroup
RUN apk add --no-cache ca-certificates wget
COPY --from=builder /out/pocketbase /pocketbase
RUN mkdir -p /pb_data && chown appuser:appgroup /pb_data
RUN mkdir -p /pb_data
VOLUME /pb_data
EXPOSE 8090
USER appuser
CMD ["/pocketbase", "serve", "--dir", "/pb_data", "--http", "0.0.0.0:8090"]
# ── runner service ───────────────────────────────────────────────────────────

View File

@@ -81,7 +81,7 @@ func saveIfAbsent(app core.App, c *core.Collection) error {
if _, err := app.FindCollectionByNameOrId(c.Name); err == nil {
return nil // already exists — skip
}
return saveIfAbsent(app, c)
return app.Save(c)
}
// ── Collection creators ───────────────────────────────────────────────────────

96
docker-bake.hcl Normal file
View File

@@ -0,0 +1,96 @@
# docker-bake.hcl — defines all five production images.
#
# Used by CI (docker/bake-action) to build and push all images in one call,
# with shared BuildKit cache and maximum parallelism:
# - backend, runner, pocketbase share the Go builder stage (built once)
# - caddy and ui build independently in parallel alongside the Go targets
#
# Local build (no push):
# docker buildx bake
#
# CI sets per-target tags and labels via --set overrides in docker/bake-action.
# ── Variables injected by CI via --set ───────────────────────────────────────
variable "DOCKER_USER" { default = "kalekber" }
variable "VERSION" { default = "dev" }
variable "COMMIT" { default = "unknown" }
variable "BUILD_TIME" { default = "" }
# ── Shared defaults ───────────────────────────────────────────────────────────
# All targets inherit these. CI overrides tags/labels per-target via --set.
target "_defaults" {
pull = true
output = ["type=image,push=false"]
}
# Registry cache shared across all targets in a run.
target "_cache" {
cache-to = ["type=inline"]
}
# ── Go targets (share the backend/ build context + builder stage) ─────────────
target "backend" {
inherits = ["_defaults", "_cache"]
context = "backend"
target = "backend"
tags = ["${DOCKER_USER}/libnovel-backend:latest"]
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-backend:latest"]
args = {
VERSION = VERSION
COMMIT = COMMIT
}
}
target "runner" {
inherits = ["_defaults", "_cache"]
context = "backend"
target = "runner"
tags = ["${DOCKER_USER}/libnovel-runner:latest"]
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-runner:latest"]
args = {
VERSION = VERSION
COMMIT = COMMIT
}
}
target "pocketbase" {
inherits = ["_defaults", "_cache"]
context = "backend"
target = "pocketbase"
tags = ["${DOCKER_USER}/libnovel-pocketbase:latest"]
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-pocketbase:latest"]
}
# ── UI (SvelteKit — separate context) ────────────────────────────────────────
target "ui" {
inherits = ["_defaults", "_cache"]
context = "ui"
tags = ["${DOCKER_USER}/libnovel-ui:latest"]
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-ui:latest"]
args = {
BUILD_VERSION = VERSION
BUILD_COMMIT = COMMIT
BUILD_TIME = BUILD_TIME
PREBUILT = "1"
}
}
# ── Caddy (custom plugins — separate context) ─────────────────────────────────
target "caddy" {
inherits = ["_defaults", "_cache"]
context = "caddy"
tags = ["${DOCKER_USER}/libnovel-caddy:latest"]
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-caddy:latest"]
}
# ── Default group: all five images ────────────────────────────────────────────
group "default" {
targets = ["backend", "runner", "pocketbase", "ui", "caddy"]
}

View File

@@ -91,7 +91,8 @@ services:
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"]
interval: 10s
timeout: 5s
retries: 5
retries: 10
start_period: 30s
# ─── Meilisearch (full-text search) ──────────────────────────────────────────
meilisearch: