#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 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 14:37:26 -03:00
1 parent f117e1e9d7
commit 13fd2a9cee
6 files changed
+44 -9

No files matched your search

+3 -2
View File
@@ -163,7 +163,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
service: [protocols, auth, storage, webmail] service: [protocols, auth, storage, webmail, migrations, rspamd]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
@@ -174,7 +174,8 @@ jobs:
- name: Build - name: Build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
context: services/${{ matrix.service }} context: ${{ (matrix.service == 'migrations' || matrix.service == 'rspamd') && '.' || format('services/{0}', matrix.service) }}
file: services/${{ matrix.service }}/Dockerfile
push: false push: false
load: true load: true
tags: lambdamail-${{ matrix.service }}:ci tags: lambdamail-${{ matrix.service }}:ci
+4 -3
View File
@@ -50,7 +50,7 @@ jobs:
# architectures is eight jobs. Declaring platform only under `include` # architectures is eight jobs. Declaring platform only under `include`
# would not multiply anything - include can add keys to existing # would not multiply anything - include can add keys to existing
# combinations, but it cannot create the combinations themselves. # 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] platform: [linux/amd64, linux/arm64]
include: include:
- platform: linux/amd64 - platform: linux/amd64
@@ -83,7 +83,8 @@ jobs:
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: 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 }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} 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 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: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
service: [protocols, auth, storage, webmail] service: [protocols, auth, storage, webmail, migrations, rspamd]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
+11
View File
@@ -19,3 +19,14 @@ services:
web: web:
build: { context: ./services/webmail } 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
+2 -4
View File
@@ -55,10 +55,9 @@ services:
# ----------------------------------------------------------- MIGRATE # ----------------------------------------------------------- MIGRATE
migrate: migrate:
image: migrate/migrate:v4.17.1 image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/migrations:${IMAGE_TAG:-latest}
restart: "no" restart: "no"
logging: *default-logging logging: *default-logging
volumes: ["./migrations:/migrations"]
depends_on: depends_on:
db: { condition: service_healthy } db: { condition: service_healthy }
entrypoint: entrypoint:
@@ -71,13 +70,12 @@ services:
# ------------------------------------------------------------ RSPAMD # ------------------------------------------------------------ RSPAMD
rspamd: rspamd:
image: rspamd/rspamd:3.9 image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/rspamd:${IMAGE_TAG:-latest}
restart: *restart restart: *restart
logging: *default-logging logging: *default-logging
environment: environment:
REDIS_PASSWORD: ${REDIS_PASSWORD} REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes: volumes:
- ./config/rspamd:/etc/rspamd/local.d:ro
- rspamd_data:/var/lib/rspamd - rspamd_data:/var/lib/rspamd
# Rspamd does NOT expand environment variables in its UCL config - a # Rspamd does NOT expand environment variables in its UCL config - a
# "${REDIS_PASSWORD}" written into a .conf reaches Redis as that literal # "${REDIS_PASSWORD}" written into a .conf reaches Redis as that literal
+13
View File
@@ -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"]
+11
View File
@@ -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