name: Release Docker Images # Docker image builds, split out of the main Release workflow because they take by far the # longest and are the only part that benefits from building on our own hardware. on: release: types: [published] workflow_dispatch: inputs: version_tag: description: 'Version tag for Docker images (e.g., 0.26.2). Auto-detected from release/* branch name if empty.' required: false type: string tag_latest: description: 'Also tag images as :latest' required: false default: false type: boolean build_multi_container: description: 'Build and push multi-container images' required: false default: true type: boolean build_all_in_one: description: 'Build and push all-in-one image' required: false default: true type: boolean runner: description: 'Where to build. "self-hosted" needs an online runner labelled "docker-build" -- if it is offline the job queues for up to 24h and then fails. Release events always use GitHub-hosted.' required: false default: github-hosted type: choice options: - github-hosted - self-hosted jobs: # Guard job to prevent releases from main branch valid-release: if: github.event_name == 'release' runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 - name: Check tag target run: | BRANCHES=$(git branch -r --contains $GITHUB_SHA) echo "Tag is contained in:" echo "$BRANCHES" if ! echo "$BRANCHES" | grep -q "origin/release/"; then echo "❌ Releases must come from a release/* branch, please recreate the release from a release branch" exit 1 fi echo "✅ Tag is on a release branch" # Detect version from input, branch name, or release tag detect-version: if: always() && (github.event_name != 'release' || needs.valid-release.result == 'success') needs: [valid-release] runs-on: ubuntu-latest outputs: version: ${{ steps.detect.outputs.version }} tag_latest: ${{ steps.detect.outputs.tag_latest }} all_in_one_exists: ${{ steps.check-images.outputs.all_in_one_exists }} multi_container_exists: ${{ steps.check-images.outputs.multi_container_exists }} steps: - name: Checkout repository uses: actions/checkout@v6 with: sparse-checkout: .github/actions/detect-release-version - name: Detect version id: detect uses: ./.github/actions/detect-release-version with: version_tag: ${{ inputs.version_tag }} tag_latest: ${{ inputs.tag_latest }} - name: Log in to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Check if Docker images already exist id: check-images run: | VERSION="${{ steps.detect.outputs.version }}" if [ -z "$VERSION" ]; then echo "all_in_one_exists=false" >> $GITHUB_OUTPUT echo "multi_container_exists=false" >> $GITHUB_OUTPUT echo "No version specified, images will be built" exit 0 fi # Check all-in-one image if docker manifest inspect ghcr.io/aliasvault/aliasvault:${VERSION} > /dev/null 2>&1; then echo "all_in_one_exists=true" >> $GITHUB_OUTPUT echo "✅ All-in-one image ghcr.io/aliasvault/aliasvault:${VERSION} exists" else echo "all_in_one_exists=false" >> $GITHUB_OUTPUT echo "❌ All-in-one image does not exist" fi # Check multi-container images (check a few key ones) MULTI_IMAGES=( "ghcr.io/aliasvault/api" "ghcr.io/aliasvault/client" "ghcr.io/aliasvault/admin" ) MULTI_EXISTS=true for IMAGE in "${MULTI_IMAGES[@]}"; do if ! docker manifest inspect ${IMAGE}:${VERSION} > /dev/null 2>&1; then echo "❌ Multi-container image ${IMAGE}:${VERSION} does not exist" MULTI_EXISTS=false break fi echo "✅ ${IMAGE}:${VERSION} exists" done echo "multi_container_exists=${MULTI_EXISTS}" >> $GITHUB_OUTPUT build-and-push-docker-multi-container: needs: [valid-release, detect-version] # Skip if images already exist during release (just tag them as latest instead), but allow manual rebuild via workflow_dispatch if: always() && (github.event_name != 'release' || needs.valid-release.result == 'success') && (github.event_name == 'release' || inputs.build_multi_container) && (github.event_name != 'release' || needs.detect-version.outputs.multi_container_exists != 'true') runs-on: ${{ inputs.runner == 'self-hosted' && 'docker-build' || 'ubuntu-latest' }} permissions: contents: read packages: write strategy: # Don't cancel the other images if one fails. fail-fast: false # On GitHub-hosted runners: cap at 2 concurrent runner slots. The heavy Client build # (~55 min: Rust→WASM + wasm-tools + WASM publish) holds one slot while the remaining # light images cycle through the second slot (~35-45 min total). This frees up the rest # of the CI worker pool for other workflows. # On self-hosted: no meaningful cap, the size of our own "docker-build" runner pool is the # real limit, so let every image queue and fill whatever runners are free. This is what lets # the pool pick these up the moment the all-in-one job finishes, instead of leaving a runner # idle. Each runner keeps its own persistent buildx cache, which carries the shared .NET # layers from one image to the next. max-parallel: ${{ inputs.runner == 'self-hosted' && 8 || 2 }} matrix: # Client is listed first so it claims a slot immediately and runs alongside the rest of the images. include: - image: client dockerfile: apps/server/AliasVault.Client/Dockerfile title: AliasVault Client description: Blazor WebAssembly client UI for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.com) - image: postgres dockerfile: apps/server/Databases/AliasServerDb/Dockerfile title: AliasVault PostgreSQL description: PostgreSQL database for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.com) - image: api dockerfile: apps/server/AliasVault.Api/Dockerfile title: AliasVault API description: REST API backend for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.com) - image: admin dockerfile: apps/server/AliasVault.Admin/Dockerfile title: AliasVault Admin description: Admin portal for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.com) - image: reverse-proxy dockerfile: apps/server/Dockerfile title: AliasVault Reverse Proxy description: Nginx reverse proxy for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.com) - image: smtp dockerfile: apps/server/Services/AliasVault.SmtpService/Dockerfile title: AliasVault SMTP Service description: SMTP service for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.com) - image: task-runner dockerfile: apps/server/Services/AliasVault.TaskRunner/Dockerfile title: AliasVault TaskRunner description: Background task runner for AliasVault. Part of multi-container setup and can be deployed via install.sh (see docs.aliasvault.com) - image: installcli dockerfile: apps/server/Utilities/AliasVault.InstallCli/Dockerfile title: AliasVault Install CLI description: Installation and configuration CLI for AliasVault. Used by install.sh for setup and configuration, not deployed as part of the application stack steps: - name: Checkout repository uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx (GitHub-hosted) if: runner.environment == 'github-hosted' uses: docker/setup-buildx-action@v4 # On self-hosted we reuse a named builder across runs so its build cache survives, which is # what makes building the images back-to-back on a single machine fast. setup-buildx-action # deletes its builder in a post-step, throwing away exactly that cache. - name: Set up Docker Buildx (self-hosted) if: runner.environment == 'self-hosted' run: | docker buildx inspect aliasvault >/dev/null 2>&1 \ || docker buildx create --name aliasvault --driver docker-container --bootstrap docker buildx use aliasvault - name: Log in to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v6 env: DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index with: images: ghcr.io/aliasvault/${{ matrix.image }} tags: | type=raw,value=${{ needs.detect-version.outputs.version }},enable=${{ needs.detect-version.outputs.version != '' }} type=raw,value=latest,enable=${{ needs.detect-version.outputs.tag_latest == 'true' }} type=ref,event=branch,enable=${{ needs.detect-version.outputs.version == '' }} type=raw,value={{branch}}-{{sha}},enable=${{ needs.detect-version.outputs.version == '' }} labels: | org.opencontainers.image.title=${{ matrix.title }} org.opencontainers.image.description=${{ matrix.description }} annotations: | org.opencontainers.image.description=${{ matrix.description }} - name: Build and push image uses: docker/build-push-action@v7 with: context: . file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64/v8 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} # Keep the persistent builder cache from growing without bound on our own hardware. - name: Prune stale build cache if: always() && runner.environment == 'self-hosted' run: docker buildx prune --force --keep-storage=50GB build-and-push-docker-all-in-one: needs: [valid-release, detect-version] # Skip if images already exist during release (just tag them as latest instead), but allow manual rebuild via workflow_dispatch if: always() && (github.event_name != 'release' || needs.valid-release.result == 'success') && (github.event_name == 'release' || inputs.build_all_in_one) && (github.event_name != 'release' || needs.detect-version.outputs.all_in_one_exists != 'true') runs-on: ${{ inputs.runner == 'self-hosted' && 'docker-build' || 'ubuntu-latest' }} permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v6 # Free up disk space of unused tools on the GitHub runner as the all-in-one build consumes a # lot of disk space. Never do this on self-hosted: it would delete the machine's own # toolchains and wipe the build cache we rely on. - name: Free up disk space if: runner.environment == 'github-hosted' run: | echo "Disk before cleanup:" df -h / sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL sudo rm -rf /usr/local/share/boost "$AGENT_TOOLSDIRECTORY" sudo docker image prune --all --force || true echo "Disk after cleanup:" df -h / - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx (GitHub-hosted) if: runner.environment == 'github-hosted' uses: docker/setup-buildx-action@v4 - name: Set up Docker Buildx (self-hosted) if: runner.environment == 'self-hosted' run: | docker buildx inspect aliasvault >/dev/null 2>&1 \ || docker buildx create --name aliasvault --driver docker-container --bootstrap docker buildx use aliasvault - name: Log in to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata for all-in-one image id: meta uses: docker/metadata-action@v6 env: DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index with: images: | ghcr.io/aliasvault/aliasvault aliasvault/aliasvault tags: | type=raw,value=${{ needs.detect-version.outputs.version }},enable=${{ needs.detect-version.outputs.version != '' }} type=raw,value=latest,enable=${{ needs.detect-version.outputs.tag_latest == 'true' }} type=ref,event=branch,enable=${{ needs.detect-version.outputs.version == '' }} type=raw,value={{branch}}-{{sha}},enable=${{ needs.detect-version.outputs.version == '' }} labels: | org.opencontainers.image.title=AliasVault All-in-One org.opencontainers.image.description=Self-contained AliasVault server including web app, with all services bundled using s6-overlay. Single container solution for easy deployment (see docs.aliasvault.com). annotations: | org.opencontainers.image.description=Self-contained AliasVault server including web app, with all services bundled using s6-overlay. Single container solution for easy deployment (see docs.aliasvault.com). - name: Build and push all-in-one image uses: docker/build-push-action@v7 with: context: . file: dockerfiles/all-in-one/Dockerfile platforms: linux/amd64,linux/arm64/v8 # Mark this as the official published image (local builds default to "aio-build"). build-args: | DEPLOYMENT_MODE=aio push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} - name: Prune stale build cache if: always() && runner.environment == 'self-hosted' run: docker buildx prune --force --keep-storage=50GB # Tag existing images as :latest without rebuilding (for release events when images already exist) tag-docker-images-latest: needs: [valid-release, detect-version] if: github.event_name == 'release' && needs.valid-release.result == 'success' && needs.detect-version.outputs.tag_latest == 'true' runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Tag all-in-one images as latest if: needs.detect-version.outputs.all_in_one_exists == 'true' run: | VERSION="${{ needs.detect-version.outputs.version }}" echo "Tagging ghcr.io/aliasvault/aliasvault:${VERSION} as latest..." docker buildx imagetools create -t ghcr.io/aliasvault/aliasvault:latest ghcr.io/aliasvault/aliasvault:${VERSION} docker buildx imagetools create -t aliasvault/aliasvault:latest aliasvault/aliasvault:${VERSION} - name: Tag multi-container images as latest if: needs.detect-version.outputs.multi_container_exists == 'true' run: | VERSION="${{ needs.detect-version.outputs.version }}" IMAGES=( "ghcr.io/aliasvault/postgres" "ghcr.io/aliasvault/api" "ghcr.io/aliasvault/client" "ghcr.io/aliasvault/admin" "ghcr.io/aliasvault/reverse-proxy" "ghcr.io/aliasvault/smtp" "ghcr.io/aliasvault/task-runner" "ghcr.io/aliasvault/installcli" ) for IMAGE in "${IMAGES[@]}"; do if docker manifest inspect ${IMAGE}:${VERSION} > /dev/null 2>&1; then echo "Tagging ${IMAGE}:${VERSION} as latest..." docker buildx imagetools create -t ${IMAGE}:latest ${IMAGE}:${VERSION} else echo "⚠️ Skipping ${IMAGE}:${VERSION} (does not exist)" fi done