Docker and deploy

Services opt into images by enabling sbt-native-packager's DockerPlugin. Deploying to multiple environments fans out over typed targets.

Green is the image path (plugin → Aggregate Docker/publish). Amber is the target fan-out: one deploy job per Target, each wired to its own GitHub Environment (approvals stay independent).

Docker paved path

lazy val service = project
  .dependsOn(coreLib)
  .enablePlugins(JavaAppPackaging, DockerPlugin)
  .settings(
    publishArtifact     := false,
    Compile / mainClass := Some("example.Main"),
    dockerBaseImage     := "eclipse-temurin:21-jre",
    Docker / packageName := "example-service",
  )

zipx detects DockerPlugin and emits a release-gated Aggregate docker job joining …/Docker/publish (or use dockerGraph). Multi-registry pushes are a custom capability with targets (see Custom capabilities). For PR-label stage ECR (before merge), see Job conditions.

DocsRender.job("docker")(Capability.docker)
docker:
  name: docker
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      with:
        fetch-depth: "0"
        fetch-tags: "true"
    - name: Setup JDK 21
      uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
      with:
        distribution: temurin
        java-version: "21"
    - uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
      with:
        disk-cache: "false"
    - name: Cache sbt
      uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
      with:
        path: |
          ~/.sbt
          ~/.cache/sbt
          ~/.cache/coursier
          target
        key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-docker
        restore-keys: |
          ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
          ubuntu-latest-jdk21-sbt-0.1.0-ci-
          ubuntu-latest-jdk21-sbt-0.1.0-
          ubuntu-latest-jdk21-sbt-
    - name: docker
      run: sbt 'service/Docker/publish'

Aggregate-by-target deploy

Default (Capability.deploy / zipxTasks.deploy): one job per Target; participating modules' commands are joined. GitHub Environments stay independent.

Escape hatch (Capability.deployGraph / zipxTasks.deployGraph): one job per (module × target).

val promote = taskKey[Unit]("promote the image")

zipxCapabilities += zipxTasks.deploy(
  participates = _.id == "service",
  command = promote,
  targets = _ => List(
    Target("staging", env = Map("TIER" -> EnvValue.plain("staging"))),
    Target(
      "prod",
      environment = Some("production"),
      env = Map("TIER" -> EnvValue.plain("prod"), "DEPLOY_ROLE" -> secret"PROD_ROLE"),
      condition = Some(JobCondition.refIs("refs/heads/main")),
    ),
  ),
  needsCapabilities = List("docker"),
  permissions = Map("id-token" -> "write", "contents" -> "read"),
)
{
  val targets = List(
    Target("staging", env = Map("TIER" -> EnvValue.plain("staging"))),
    Target(
      "prod",
      environment = Some("production"),
      env = Map("TIER" -> EnvValue.plain("prod"), "DEPLOY_ROLE" -> secret"PROD_ROLE"),
      condition = Some(JobCondition.refIs("refs/heads/main")),
    ),
  )
  DocsRender.jobs("deploy-staging", "deploy-prod")(
    Capability.deploy(
      participates = _.id == "service",
      command = n => s"${n.id}/promote",
      targets = _ => targets,
      needsCapabilities = Nil,
    )
  )
}
deploy-staging:
  name: deploy (staging)
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')
  env:
    TIER: staging
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      with:
        fetch-depth: "0"
        fetch-tags: "true"
    - name: Setup JDK 21
      uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
      with:
        distribution: temurin
        java-version: "21"
    - uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
      with:
        disk-cache: "false"
    - name: Cache sbt
      uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
      with:
        path: |
          ~/.sbt
          ~/.cache/sbt
          ~/.cache/coursier
          target
        key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-deploy-staging
        restore-keys: |
          ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
          ubuntu-latest-jdk21-sbt-0.1.0-ci-
          ubuntu-latest-jdk21-sbt-0.1.0-
          ubuntu-latest-jdk21-sbt-
    - name: deploy
      run: sbt 'service/promote'
deploy-prod:
  name: deploy (prod)
  runs-on: ubuntu-latest
  if: (startsWith(github.ref, 'refs/tags/v')) && (github.ref == 'refs/heads/main')
  environment: production
  env:
    DEPLOY_ROLE: ${{ secrets.PROD_ROLE }}
    TIER: prod
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      with:
        fetch-depth: "0"
        fetch-tags: "true"
    - name: Setup JDK 21
      uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
      with:
        distribution: temurin
        java-version: "21"
    - uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
      with:
        disk-cache: "false"
    - name: Cache sbt
      uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
      with:
        path: |
          ~/.sbt
          ~/.cache/sbt
          ~/.cache/coursier
          target
        key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-deploy-prod
        restore-keys: |
          ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
          ubuntu-latest-jdk21-sbt-0.1.0-ci-
          ubuntu-latest-jdk21-sbt-0.1.0-
          ubuntu-latest-jdk21-sbt-
    - name: deploy
      run: sbt 'service/promote'

Approval is enforced by GitHub, not zipx. zipx emits the environment: binding; GitHub pauses for protection rules. Put deploy config in project/*.scala as typed lists (see examples/monorepo).