Docker and deploy
Skip until you ship a service image or more than one environment. Enable
sbt-native-packager's DockerPlugin on the module; zipx adds the docker
job. Targets are named environments (staging, production) with separate GitHub Environment approvals.
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). Pushing one image to several registries stays one job; see Registries are destinations, targets are
environments below. For PR-label stage ECR (before merge), see Job conditions. Independent library versions
(Ship / ShipGroup) do not move docker onto Gate.OnDefaultPush; image and deploy still wait on a human v*
tag. See Independent versions.
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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: docker
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: docker
run: sbt 'service/Docker/publish'Registries are destinations, targets are environments
The rule of thumb, because getting it backwards is expensive:
| The destinations are | Shape | Why |
|---|---|---|
| Registries for one image | withSharedTargets (TargetFanOut.SharedJob) | Docker / publish builds once and pushes every dockerAliases entry, so N registries is one job |
| Deploy environments | withTargets (TargetFanOut.JobPerTarget, the default) | Each really is a separate job: its own approval, its own environment:, its own if: |
targets multiplies jobs. That is right for the second row and wrong for the first: 6 registries across 8 images is
48 jobs under JobPerTarget and 8 under SharedJob, and the 48 each rebuild the same image, so nothing guarantees
the registries hold identical bytes. One build pushed N times does guarantee it.
zipxCapabilities += Capability.docker.withSharedTargets(
List(
Target(TargetName("us"), env = Map("AWS_REGION" -> EnvValue.plain("us-east-1"), "AWS_ROLE_TO_ASSUME" -> secret"US_ROLE")),
Target(TargetName("eu"), env = Map("AWS_REGION" -> EnvValue.plain("eu-west-1"), "AWS_ROLE_TO_ASSUME" -> secret"EU_ROLE")),
)
).copy(extraSteps = ZipxAws.sharedLoginSteps)
One job, one image, OIDC then ECR login per destination. On AWS, ZipxAws.dockerPublishAll(registries) is that whole
expression (see Packs).
DocsRender.job("docker")(
Capability.docker.withSharedTargets(
List(
Target(
TargetName("us"),
env = Map("AWS_REGION" -> EnvValue.plain("us-east-1"), "AWS_ROLE_TO_ASSUME" -> secret"US_ROLE"),
),
Target(
TargetName("eu"),
env = Map("AWS_REGION" -> EnvValue.plain("eu-west-1"), "AWS_ROLE_TO_ASSUME" -> secret"EU_ROLE"),
),
)
)
)docker:
name: docker
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
env:
ZIPX_EU_AWS_REGION: eu-west-1
ZIPX_EU_AWS_ROLE_TO_ASSUME: ${{ secrets.EU_ROLE }}
ZIPX_US_AWS_REGION: us-east-1
ZIPX_US_AWS_ROLE_TO_ASSUME: ${{ secrets.US_ROLE }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: "0"
fetch-tags: "true"
- name: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: docker
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: docker
run: sbt 'service/Docker/publish'Why the env keys are prefixed
Both destinations want AWS_ROLE_TO_ASSUME. Merging unprefixed would keep whichever one came last, and the job would
push twice to one account while silently skipping the other, so a shared job puts each destination's env under
Target.envKey: ZIPX_<TARGET>_<KEY>. Target.envName(name) is how a step reads it back, and the fixed ZIPX_
anchor is what makes that total: a target named github would otherwise derive a GITHUB_… name, which EnvName
refuses because GitHub reserves the prefix.
extraSteps receives every destination as StepContext.destinations (and StepContext.target is None, since there
is no single target a shared job belongs to), which is how one bundle emits one login per registry.
What a shared job refuses
A Target.condition or Target.environment under SharedJob is a generate-time error, not a silently dropped
field:
zipx: capability 'docker' target 'us' sets a condition, which one shared job cannot honor per destination.
Use TargetFanOut.JobPerTarget (the default) when destinations need their own condition, or drop it and gate
the whole job with Capability.condition.
Dropping it would push to a registry the author said to skip; applying it job-wide would skip the ones that were fine.
Both are wrong answers arrived at quietly, so zipx declines to pick one. Per-destination approval is the second row of
the table: that is what JobPerTarget is for.
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(TargetName("staging"), env = Map("TIER" -> EnvValue.plain("staging"))),
Target(
TargetName("prod"),
environment = Some("production"),
env = Map("TIER" -> EnvValue.plain("prod"), "DEPLOY_ROLE" -> secret"PROD_ROLE"),
condition = Some(JobCondition.varNonEmpty("DEPLOY_PROD_ENABLED")),
),
),
needsCapabilities = List(Capability.DockerName),
permissions = Map("id-token" -> "write", "contents" -> "read"),
)
Note what the prod condition is not: refIs("refs/heads/main"). Capability.deploy gates OnReleaseTag, the two are
ANDed, and no ref is both a v* tag and refs/heads/main, so zipx refuses to generate that pair outright (see
[[JobConditions]]). Pass gate = Gate.Always if deploy-from-main is what you want.
{
val targets = List(
Target(TargetName("staging"), env = Map("TIER" -> EnvValue.plain("staging"))),
Target(
TargetName("prod"),
environment = Some("production"),
env = Map("TIER" -> EnvValue.plain("prod"), "DEPLOY_ROLE" -> secret"PROD_ROLE"),
condition = Some(JobCondition.varNonEmpty("DEPLOY_PROD_ENABLED")),
),
)
DocsRender.jobs("deploy-staging", "deploy-prod")(
Capability.deploy(
participates = _.id == "service",
command = n => SbtCommand.module(n, SbtCommand.unsafeTask("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: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: deploy-staging
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: deploy
run: sbt 'service/promote'
deploy-prod:
name: deploy (prod)
runs-on: ubuntu-latest
if: (startsWith(github.ref, 'refs/tags/v')) && (vars.DEPLOY_PROD_ENABLED != '')
environment: production
env:
DEPLOY_ROLE: ${{ secrets.PROD_ROLE }}
TIER: prod
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: "0"
fetch-tags: "true"
- name: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: "21"
runner-os: ubuntu-latest
cache-key-suffix: deploy-prod
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- 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).