Andrew Mercer
on this page

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.