From 13fd2a9ceec46da422089f1a650994b2a7e77cb4 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 2 Aug 2026 14:37:26 -0300 Subject: [PATCH] #21: ship the migrations and the Rspamd config inside images The deployment failed on the migrate service. Coolify rewrites a relative bind mount to a path under its own application directory, and when that resolves to an empty directory golang-migrate finds no migrations and exits 1 - with nothing useful in the deployment log to say why. A deployment that pulls its images has no business also depending on files being at a particular path on the host. Both mounts are now baked into images built from the repository root: migrations - the schema, so migrate always has it rspamd - actions.conf and ratelimit.conf The Rspamd one mattered for a quieter reason: Rspamd starts happily with an empty local.d, so a mount that resolved to nothing would have left the rate limits and score thresholds unapplied. The defence would have looked configured and done nothing. Both images join the CI and release matrices, building from the root context. Verified by building them, checking their contents, and bringing the whole stack up - Rspamd reports its nine rate directives loaded from the image. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 +++-- .github/workflows/release.yml | 7 ++++--- docker-compose.build.yaml | 11 +++++++++++ docker-compose.yaml | 6 ++---- services/migrations/Dockerfile | 13 +++++++++++++ services/rspamd/Dockerfile | 11 +++++++++++ 6 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 services/migrations/Dockerfile create mode 100644 services/rspamd/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a5d0eb..1948f27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,7 @@ jobs: strategy: fail-fast: false matrix: - service: [protocols, auth, storage, webmail] + service: [protocols, auth, storage, webmail, migrations, rspamd] steps: - uses: actions/checkout@v7 @@ -174,7 +174,8 @@ jobs: - name: Build uses: docker/build-push-action@v7 with: - context: services/${{ matrix.service }} + context: ${{ (matrix.service == 'migrations' || matrix.service == 'rspamd') && '.' || format('services/{0}', matrix.service) }} + file: services/${{ matrix.service }}/Dockerfile push: false load: true tags: lambdamail-${{ matrix.service }}:ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fbf3a7..2f04576 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: # architectures is eight jobs. Declaring platform only under `include` # would not multiply anything - include can add keys to existing # combinations, but it cannot create the combinations themselves. - service: [protocols, auth, storage, webmail] + service: [protocols, auth, storage, webmail, migrations, rspamd] platform: [linux/amd64, linux/arm64] include: - platform: linux/amd64 @@ -83,7 +83,8 @@ jobs: id: build uses: docker/build-push-action@v7 with: - context: services/${{ matrix.service }} + context: ${{ (matrix.service == 'migrations' || matrix.service == 'rspamd') && '.' || format('services/{0}', matrix.service) }} + file: services/${{ matrix.service }}/Dockerfile platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true @@ -117,7 +118,7 @@ jobs: strategy: fail-fast: false matrix: - service: [protocols, auth, storage, webmail] + service: [protocols, auth, storage, webmail, migrations, rspamd] steps: - uses: actions/checkout@v7 diff --git a/docker-compose.build.yaml b/docker-compose.build.yaml index 8e5bacc..50d7f84 100644 --- a/docker-compose.build.yaml +++ b/docker-compose.build.yaml @@ -19,3 +19,14 @@ services: web: build: { context: ./services/webmail } + + # These two carry repository files, so their context is the root. + migrate: + build: + context: . + dockerfile: services/migrations/Dockerfile + + rspamd: + build: + context: . + dockerfile: services/rspamd/Dockerfile diff --git a/docker-compose.yaml b/docker-compose.yaml index 999e3c8..c534626 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -55,10 +55,9 @@ services: # ----------------------------------------------------------- MIGRATE migrate: - image: migrate/migrate:v4.17.1 + image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/migrations:${IMAGE_TAG:-latest} restart: "no" logging: *default-logging - volumes: ["./migrations:/migrations"] depends_on: db: { condition: service_healthy } entrypoint: @@ -71,13 +70,12 @@ services: # ------------------------------------------------------------ RSPAMD rspamd: - image: rspamd/rspamd:3.9 + image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/rspamd:${IMAGE_TAG:-latest} restart: *restart logging: *default-logging environment: REDIS_PASSWORD: ${REDIS_PASSWORD} volumes: - - ./config/rspamd:/etc/rspamd/local.d:ro - rspamd_data:/var/lib/rspamd # Rspamd does NOT expand environment variables in its UCL config - a # "${REDIS_PASSWORD}" written into a .conf reaches Redis as that literal diff --git a/services/migrations/Dockerfile b/services/migrations/Dockerfile new file mode 100644 index 0000000..481b04d --- /dev/null +++ b/services/migrations/Dockerfile @@ -0,0 +1,13 @@ +# The migrations travel inside an image rather than a bind mount. +# +# A deployment that pulls its images has no reason to also depend on files +# being present at a particular path on the host: the orchestrator rewrites +# relative bind mounts to its own layout, and an empty directory silently +# becomes "no migrations found" - which fails the deploy with no useful error. +# +# Build context is the repository root. +FROM migrate/migrate:v4.17.1 + +COPY migrations /migrations + +ENTRYPOINT ["migrate", "-path=/migrations"] diff --git a/services/rspamd/Dockerfile b/services/rspamd/Dockerfile new file mode 100644 index 0000000..37e1f32 --- /dev/null +++ b/services/rspamd/Dockerfile @@ -0,0 +1,11 @@ +# Rspamd with its configuration baked in. +# +# The configuration used to arrive through a bind mount. Rspamd starts happily +# without it, so a mount that resolved to an empty directory would leave the +# rate limits and score thresholds silently unapplied - the defence would look +# configured and do nothing. +# +# Build context is the repository root. +FROM rspamd/rspamd:3.9 + +COPY config/rspamd /etc/rspamd/local.d