Structure:

```
main.bicep            # params + module calls only
modules/network.bicep
modules/storage.bicep
modules/webapp.bicep
bicepconfig.json
```

1. **Modules.** Each module takes explicit params and exposes outputs. `module net 'modules/network.bicep' = { name: 'net-${env}', params: { ... } }`. No cross-module resource references except via outputs.
2. **Private registry.** `az bicep publish --file modules/storage.bicep --target br:[registry].azurecr.io/bicep/storage:v1` then reference `br:[registry].azurecr.io/bicep/storage:v1`. Version shared modules; `v1`, `v2`, never `latest` in prod templates.
3. **CI gate.** `az bicep build` (compile) then `az deployment group what-if` against the target group. Fail the pipeline on unexpected Delete lines.
4. **Deterministic deployment names.** `--name ${env}-$(Build.BuildId)` or a content hash. Traceable in the activity log.
5. **Parameters per env.** `params/prod.bicepparam`. No env conditionals inside main.bicep; the param file is the env.

Traps:

- `latest` tag on registry modules = non-reproducible deploys.
- Outputs referencing secrets: mark `@secure()` and never print them in CI logs.
- what-if against the wrong resource group approves changes for the wrong environment. The CI job's `--resource-group` must come from the same env as the param file.

Verify: CI what-if output archived per build, prod deploy shows only intended changes, registry module versions pinned in every template.