diff --git a/.gitea/workflows/qa-build.yml b/.gitea/workflows/qa-build.yml
new file mode 100644
index 0000000..db2659b
--- /dev/null
+++ b/.gitea/workflows/qa-build.yml
@@ -0,0 +1,152 @@
+name: Build QA Image
+
+# Manual only. Builds the QA image from a chosen ref, pushes it to the Gitea
+# container registry, and emails when it is ready to redeploy.
+#
+# It deliberately does NOT restart the QA stack. Redeploying stays a human
+# action in Portainer, so nothing changes what is running without someone
+# deciding it should.
+on:
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: Branch, tag, or commit to build
+ required: true
+ default: main
+
+env:
+ IMAGE: gitea.bermudalamb.synology.me/bermudalamb/redefined-designs
+ # The build runs against a Docker-in-Docker service rather than the host
+ # daemon. Mounting the host socket into the runner would give every workflow
+ # on every branch root-equivalent control of the NAS, production included.
+ # Because the image is pushed to a registry, it does not need to survive in
+ # the build daemon.
+ DOCKER_HOST: tcp://docker:2375
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ services:
+ docker:
+ image: docker:27-dind
+ options: --privileged
+ env:
+ DOCKER_TLS_CERTDIR: ""
+
+ steps:
+ - name: Check required configuration
+ run: |
+ missing=""
+ [ -n "${{ secrets.REGISTRY_TOKEN }}" ] || missing="$missing REGISTRY_TOKEN"
+ [ -n "${{ vars.REGISTRY_USER }}" ] || missing="$missing REGISTRY_USER"
+ [ -n "${{ secrets.BREVO_API_KEY }}" ] || missing="$missing BREVO_API_KEY"
+ [ -n "${{ vars.QA_NOTIFY_TO }}" ] || missing="$missing QA_NOTIFY_TO"
+ [ -n "${{ vars.QA_NOTIFY_FROM }}" ] || missing="$missing QA_NOTIFY_FROM"
+ if [ -n "$missing" ]; then
+ echo "::error::Missing configuration:$missing"
+ echo "Secrets go in Settings > Actions > Secrets; variables in Settings > Actions > Variables."
+ exit 1
+ fi
+ echo "All required secrets and variables are present."
+
+ - name: Checkout ${{ inputs.ref }}
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.ref }}
+ fetch-depth: 0
+
+ - name: Ensure a docker CLI is available
+ run: |
+ if command -v docker >/dev/null 2>&1; then
+ echo "docker CLI already present: $(docker --version)"
+ exit 0
+ fi
+ echo "docker CLI missing from the runner image; installing the static binary."
+ curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz -o /tmp/docker.tgz
+ tar -xzf /tmp/docker.tgz -C /tmp
+ install -m 0755 /tmp/docker/docker /usr/local/bin/docker
+ docker --version
+
+ - name: Wait for the build daemon
+ run: |
+ # A privileged service container is the one runner capability this
+ # workflow cannot verify in advance. Fail here with an explanation
+ # rather than at `docker build` with a connection refused.
+ for i in $(seq 1 30); do
+ if docker info >/dev/null 2>&1; then
+ echo "Build daemon reachable after ${i}s."
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "::error::No Docker daemon at $DOCKER_HOST after 30s."
+ echo "The dind service needs privileged containers. If the runner"
+ echo "forbids them, this workflow cannot build without host socket access."
+ exit 1
+
+ - name: Record what is being built
+ id: meta
+ run: |
+ echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
+ echo "full_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+ {
+ echo "subject<
To deploy it: open the redefined-designs-qa stack in Portainer and redeploy with Pull latest image enabled.
Migrations run automatically as the container starts — check docker logs redefined-designs-qa-syn shows the migration output before listening on 3000, and that it appears only once.