Postfix CI/CD Deployment — Step by Step¶
Goal: push to main on GitLab → image builds and lands in GitLab's
container registry → a systemd timer on your homelab host pulls it and
recreates the container. No Watchtower, no Docker socket exposure, no
inbound connection into your homelab required.
git push main
|
v
GitLab CI builds image, pushes to registry.gitlab.com/andrewmercer/postfix
|
v
(nothing happens immediately - homelab is not listening for this)
|
v
systemd timer fires every 5 min on your homelab host
|
v
docker compose pull postfix (checks registry, downloads if new)
docker compose up -d postfix (no-op if image unchanged; recreates if new)
Step 1 — Switch compose.yml from build: to image:¶
Right now your postfix service builds locally from the Containerfile
every time. For CI/CD to work, the homelab needs to pull a pre-built
image instead of building it itself.
services:
postfix:
image: registry.gitlab.com/andrewmercer/postfix:latest
container_name: postfix
restart: unless-stopped
hostname: ${MYHOSTNAME:-mail.example.com}
ports:
- "25:25"
volumes:
- ./config/main.cf.template:/etc/postfix/main.cf.template:ro
- ./config/mailname.template:/etc/postfix/mailname.template:ro
- ./config/sasl_passwd:/etc/postfix/sasl_passwd.src:ro
- ./config/generic:/etc/postfix/generic.src:ro
- /etc/letsencrypt/live/${MYDOMAIN}:/etc/letsencrypt/live/${MYDOMAIN}:ro
- /etc/letsencrypt/archive/${MYDOMAIN}:/etc/letsencrypt/archive/${MYDOMAIN}:ro
- postfix_spool:/var/spool/postfix
- postfix_lib:/var/lib/postfix
env_file:
- .env
environment:
- TZ=UTC
- MYDOMAIN=${MYDOMAIN}
- MYHOSTNAME=${MYHOSTNAME}
- MYNETWORKS=${MYNETWORKS}
- RELAYHOST=${RELAYHOST}
networks:
- default
networks:
default:
name: ${NETWORK}
external: true
volumes:
postfix_spool:
postfix_lib:
Remove build: . entirely - replaced by image:. The Containerfile
still exists in the repo (GitLab CI needs it to build), but your homelab
host will never build it locally again.
Step 2 — Add .gitlab-ci.yml to the repo¶
stages:
- build
build:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker build -t $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $CI_REGISTRY_IMAGE:latest
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
only:
- main
$CI_REGISTRY_IMAGE, $CI_REGISTRY_USER, $CI_REGISTRY_PASSWORD are all
GitLab predefined CI/CD variables - nothing to configure manually as long
as you're using GitLab's own container registry (Settings → CI/CD →
Variables shows these are auto-populated; you don't need to create them).
Commit and push this file to main. Check CI/CD → Pipelines in
GitLab to confirm it runs and succeeds.
Step 3 — Confirm the image landed in the registry¶
Packages and registries → Container Registry in your GitLab project.
You should see registry.gitlab.com/andrewmercer/postfix with a
latest tag and a SHA-tagged one from Step 2's push.
Step 4 — Authenticate your homelab host to GitLab's registry¶
One-time, on the homelab host (not in a container):
docker login registry.gitlab.com -u <your-gitlab-username> -p <personal-access-token>
Generate the token at GitLab → your avatar → Edit profile → Access
Tokens, scope: read_registry is sufficient (no need for write access
from the homelab side - it only ever pulls).
This caches a credential in ~/.docker/config.json that docker compose
pull will use automatically going forward - no need to repeat this
unless the token expires or you revoke it.
Step 5 — Pull the image manually once, to confirm everything works¶
cd /path/to/your/postfix/repo
docker compose pull postfix
docker compose up -d postfix
docker logs postfix
This should behave identically to your local build:-based runs from
tonight - same entrypoint, same templates, same env vars - just sourced
from the registry instead of built on the spot.
Step 6 — Set up the systemd timer for automatic pulls¶
/etc/systemd/system/postfix-update.service
[Unit]
Description=Pull latest postfix image and recreate if updated
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
WorkingDirectory=/path/to/your/postfix/repo
ExecStart=/usr/bin/docker compose pull postfix
ExecStart=/usr/bin/docker compose up -d postfix
/etc/systemd/system/postfix-update.timer
[Unit]
Description=Run postfix-update every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now postfix-update.timer
systemctl list-timers | grep postfix
Step 7 — Disable the old systemd unit that manages postfix directly¶
This is the unit from earlier tonight that was fighting your manual
docker compose commands. It must not exist anymore, or it'll conflict
with Step 6's timer:
systemctl list-units | grep -i postfix
sudo systemctl stop <old-unit-name>
sudo systemctl disable <old-unit-name>
sudo rm /etc/systemd/system/<old-unit-name>.service
sudo systemctl daemon-reload
Step 8 — End-to-end test¶
# make a trivial change, e.g. a comment in entrypoint.sh
git add . && git commit -m "test: ci/cd pipeline" && git push origin main
Watch CI/CD → Pipelines in GitLab until it's green, then wait up to 5 minutes and check the homelab host:
journalctl -u postfix-update -f
You should see it pull the new image and recreate the container, without
you touching the homelab host at all after the git push.
Where cert renewal fits in (separate, from the earlier doc)¶
Not part of this pipeline - acme.sh on the host handles that
independently, with its own cron and --reloadcmd "docker exec postfix
postfix reload". Doesn't touch or depend on anything above.
Later, when you're ready for it (not now)¶
Prometheus/Grafana/Alertmanager would sit alongside this, not replace it - they're for observability (metrics, dashboards, alerting on container health/mail queue depth/etc.), not deployment. Same relationship as the "Watchtower isn't a cert-renewal tool" lesson from earlier: different concern, different tool, wire it in later without needing to touch this pipeline at all.