← Back to blog
Compliance Engineering16 min read

DORA and NIS2 as Code: Policy-as-Code Compliance Evidence for Spring Boot on Kubernetes

Mapping DORA and NIS2 engineering requirements to Kyverno policies, signed images with SBOMs, Resilience4j drills, Actuator audit events and restore drills, with a CI pipeline that stores audit evidence for every deploy.

Compliance work tends to stall in the same place. A requirement gets filed on the security team's board, and the people who could actually change the payments service never see it. Three quarters later an auditor asks for proof, and the team learns that "we do this" and "we can show this" are two separate claims.

I write Java for systems that move money. My background includes high-traffic airline platforms, OpenBanking PSD2 integrations and a startup I co-founded. I should be plain about one thing: those were not DORA or NIS2 audits. What carried over from them was a habit. Checks that run on every deploy keep working, and checks that live in a document drift. This post applies that habit to the two EU regulations, from the point of view of the person who has to write the YAML and the Java.

A boundary before anything else. This is an engineering pattern, not legal advice and not an official compliance mapping. The regulation text, the technical standards and your supervisor's reading decide what you owe, and your compliance and legal colleagues own that reading. I only cover what an engineer can do once a requirement arrives: what to ship so a pipeline run can demonstrate it.

What a pipeline can prove, and what it cannot

A rule of thumb I use: for any control that is technical in nature, I want a pipeline run to be able to produce evidence of it. A wiki sentence saying "all images are scanned" is a statement of intent. A signed scan report tied to the exact commit, with a timestamp, sitting in a bucket nobody on the team can edit, is something an auditor can examine.

That rule has limits, and pretending otherwise would be dishonest. Management-body accountability, staff training, contract clauses with ICT providers, exit strategies and the governance of the whole risk framework are not provable by a build. Auditors also separate design effectiveness (is the control sensibly built?) from operating effectiveness (did it actually run, consistently, over the period?). A green pipeline speaks mainly to the second one, and only for the checks it contains. Treat everything below as the machine-checkable slice of a larger programme.

For that slice, I describe each control with three parts:

  1. Mechanism: the thing that enforces or measures (an admission policy, a circuit breaker, a probe).
  2. Artifact: what the mechanism leaves behind (a policy report, an SBOM, an audit event, a drill result).
  3. Retention: where the artifact lives, for how long, and how it connects to one specific deploy.

Here is how those parts line up for the areas I work on. The areas are my reading of the texts, not legal citations.

AreaMechanismArtifactWhere it stops
Change and configuration safety on production workloadsAdmission policies (Kyverno or Gatekeeper)Policy report per deploySays nothing about governance or risk appetite
Supply chain and third-party softwareSigned images, CycloneDX SBOM, scan attestationCosign signature, SBOM, scan resultA snapshot at build time, not ongoing monitoring
Resilience testingResilience4j fault drills, probes, staging chaosDrill result JSON per releaseNot a substitute for a full testing programme
Logging and detectionActuator audit events, OpenTelemetry tracesQueryable trail with trace IDsNeeds a human process to review it
Backup and recoveryScheduled restore drills with measured RTO/RPODrill report against targetsCovers the data tier, not the whole recovery chain
Incident clocksTimestamped alert classificationIncident timelineClassification criteria come from compliance

Before any of that, though, there is a prerequisite I skipped for years: an inventory. DORA is built around knowing your ICT assets and how critical each is, and a policy has to be scoped to something. I keep a small register listing each service, its owner team, and a criticality tier agreed with the business. The payments namespace in the examples below is only the first entry on that list, and the tier decides how strict the policies and drills are for each service.

Admission control: the policy that says no

Kubernetes admission is the point where "we require X" turns into "the cluster refuses without X". I have used both Kyverno and OPA Gatekeeper. Kyverno expresses policy in the same YAML dialect as the rest of your manifests, which lowers the barrier for service teams reviewing a change. Gatekeeper makes sense if people on your platform team already write Rego or you share constraint templates across many clusters. Choose one per platform; running two means two sets of reports to explain.

The policy below blocks pods in payments unless the image was signed by our CI identity and carries a CycloneDX SBOM attestation:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: payments-require-signed-image-with-sbom
  annotations:
    policies.kyverno.io/title: Signed images with SBOM for payment workloads
    compliance.internal/control-id: SUPPLY-001
spec:
  validationFailureAction: Enforce
  webhookTimeoutSeconds: 15
  failurePolicy: Fail
  rules:
    - name: verify-signature-and-sbom
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: [payments]
      verifyImages:
        - imageReferences:
            - "registry.example.eu/payments/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/example-org/*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: https://rekor.sigstore.dev
          attestations:
            - type: https://cyclonedx.org/bom

The control-id annotation is just a string, but it is the join key between the cluster and the control register. When someone asks which control covers supply chain integrity, I answer SUPPLY-001, and the policy report for each deploy carries the same ID.

Rollout: Audit first, and read the output

Never start in Enforce. Run the policy in Audit on a staging cluster and read the reports. In my experience the first reading is humbling, because policies written from a checklist collide with workloads nobody remembered: an init container pulling an unsigned utility image, a vendor sidecar on a latest tag, a CronJob owned by a team that left. Count the violations by rule, fix the real ones, and write exceptions for the legitimate leftovers. Only then switch to Enforce, and do it namespace by namespace. Turning it on everywhere on day one is how a compliance project becomes the cause of a payments outage.

Beyond signatures, the baseline rules I apply to a regulated namespace come mostly from the Kubernetes Pod Security Standards. The ones I check hardest are pinned image digests instead of floating tags, runAsNonRoot, no privileged containers, and an owner label that names a team rather than a person. Resource limits belong on the list as well, for a more practical reason: a pod with no memory limit can starve its neighbours.

The admission controller is production infrastructure too

An admission webhook sits in the path of every deploy and every pod restart that matches it, so its own availability needs thought:

  • Replicas. Run the Kyverno admission controller with at least three replicas across zones and a PodDisruptionBudget. A single replica means any node drain can freeze deployments.
  • Failure policy. failurePolicy: Fail is the safer choice for security but turns an unreachable webhook into a blocked cluster. Ignore keeps the cluster moving but silently disables the control. I use Fail for the payments namespace and make sure the webhook is healthy through alerting, and I accept Ignore only where the control is advisory.
  • Scope. Exclude kube-system and the Kyverno namespace itself with namespace selectors. Otherwise a policy can block the very pods that make the policy engine run, and recovery becomes a manual job with kubectl and the webhook configuration.
  • Latency. Signature verification calls out to the registry and transparency log. Keep webhookTimeoutSeconds low enough that a slow Rekor lookup fails fast, and cache verification results. If the transparency log is unreachable during a scale-up event, new pods cannot start, so decide in advance whether that dependency is acceptable or whether you verify against a mirrored log.

Exceptions and break-glass

The first thing a good auditor asks about is exceptions. A policy engine with no sanctioned bypass invites people to disable it at 3 AM. Two paths cover most cases.

For planned, long-lived exceptions, use a Kyverno PolicyException, kept in Git, reviewed like any other change, and carrying an expiry date and an owner in its annotations:

apiVersion: kyverno.io/v2
kind: PolicyException
metadata:
  name: legacy-reporting-unsigned-image
  namespace: payments
  annotations:
    compliance.internal/approved-by: platform-lead
    compliance.internal/expires: "2027-01-31"
spec:
  exceptions:
    - policyName: payments-require-signed-image-with-sbom
      ruleNames: [verify-signature-and-sbom]
  match:
    any:
      - resources:
          kinds: [Pod]
          namespaces: [payments]
          names: ["legacy-reporting-*"]

For emergencies, the break-glass route is a documented, access-controlled procedure: a named on-call role applies a short-lived exception, an alert fires to the security channel the moment it is created, and a follow-up ticket is mandatory. The evidence an auditor wants is that the bypass was rare, approved, logged and removed. A scheduled job that lists expired or orphaned exceptions and opens tickets for them closes the loop.

Signed images and SBOMs: knowing what you run

Third-party risk in DORA reaches past the vendors you hold contracts with. Your Maven dependency tree is also a supplier list, and each entry in it is a party whose code runs inside your payment path.

On the Java side, the CycloneDX Maven plugin produces the SBOM during the build. In the pipeline, the image is pushed, Cosign signs its digest (a tag can be moved; a digest cannot), and the SBOM is attached as an attestation. A scanner such as Grype or Trivy reads the SBOM, and its result becomes an attestation too, so the admission policy above can later require it.

Once SBOMs are stored per release, a question like "which production images ship jackson-databind below a given version?" turns into a query rather than a round of messages to every team. That speed is the practical payoff, and it is what makes upgrade campaigns tractable. I wrote about one such case in Spring Boot 4.1 SSRF mitigation and gRPC auto-configuration, where knowing exactly which services were affected decided the order of work.

One limit deserves emphasis. A signed attestation proves who built the image and what the scanner reported at build time. Vulnerability data changes daily, so a clean scan from last month says little about today. Load stored SBOMs into a continuous monitor such as OWASP Dependency-Track, which re-evaluates them against fresh advisories and alerts when a component you already deployed becomes vulnerable. The build-time gate stops new problems; the monitor catches the old ones that age badly.

Resilience testing: prove the fallback fires

DORA expects regular testing of operational resilience, with deeper exercises for the more critical entities. A circuit breaker configuration is nowhere near a testing programme, and I would not present it as one. The building blocks, however, are code, and code can be exercised on every release.

First, the configuration should state its failure behaviour explicitly instead of inheriting defaults:

@Configuration
class PaymentGatewayResilienceConfig {

    @Bean
    CircuitBreakerConfig cardNetworkBreakerConfig() {
        return CircuitBreakerConfig.custom()
            .slidingWindowType(SlidingWindowType.TIME_BASED)
            .slidingWindowSize(30)
            .minimumNumberOfCalls(20)
            .failureRateThreshold(40f)
            .slowCallDurationThreshold(Duration.ofMillis(1200))
            .slowCallRateThreshold(60f)
            .waitDurationInOpenState(Duration.ofSeconds(20))
            .permittedNumberOfCallsInHalfOpenState(5)
            .recordExceptions(IOException.class, TimeoutException.class)
            .ignoreExceptions(CardDeclinedException.class)
            .build();
    }

    @Bean
    CircuitBreakerRegistry breakerRegistry(CircuitBreakerConfig cardNetworkBreakerConfig,
                                           ResilienceEvidenceRecorder recorder) {
        CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(cardNetworkBreakerConfig);
        registry.getEventPublisher().onEntryAdded(added ->
            added.getAddedEntry().getEventPublisher()
                .onStateTransition(recorder::recordTransition));
        return registry;
    }
}

Look at ignoreExceptions(CardDeclinedException.class). A declined card is a legitimate business outcome. If it counts as a downstream failure, an ordinary evening of customers with empty accounts can open the breaker and take down a healthy path. It is an easy mistake to make when exception types are shared across layers.

Second, the drill. A test in the release pipeline starts a stub of the downstream service (WireMock handles this well), injects latency and errors, and asserts what an operator would observe: the breaker opens within the expected window, the fallback queues the payment rather than dropping it, and the readiness probe reports the degraded state truthfully. The test writes a JSON file with the scenario, thresholds, observed time-to-open and fallback outcome, and the pipeline archives it.

Probes matter here more than they get credit for. A liveness probe that restarts a pod because a dependency is slow converts a degraded service into a restart storm. I keep liveness shallow (the JVM responds), tie readiness to the ability to serve, including breaker state for hard dependencies, and add a startup probe so a slow Spring context start is not killed halfway. Setting management.endpoint.health.probes.enabled=true gives you separate liveness and readiness groups.

Staging chaos experiments, such as killing pods and adding network delay, cover what a stub cannot. Schedule them so they run regardless of anyone's goodwill; the scheduled run's output is the evidence.

Audit trails: Actuator events and OpenTelemetry

Logging requirements boil down to one question: after an incident, can you reconstruct who did what, when, and which systems were involved? Free-form application logs rarely manage it. Structured audit events with trace correlation do.

Spring Boot Actuator already includes an audit event model. I publish domain-significant actions through it and stamp each with the current trace ID, so an audit record leads straight to the distributed trace:

@Component
class PaymentAuditPublisher {

    private final ApplicationEventPublisher publisher;
    private final Tracer tracer;

    PaymentAuditPublisher(ApplicationEventPublisher publisher, Tracer tracer) {
        this.publisher = publisher;
        this.tracer = tracer;
    }

    void paymentLimitOverridden(String operatorId, String accountRef, BigDecimal newLimit) {
        Span current = tracer.currentSpan();
        String traceId = current != null ? current.context().traceId() : "no-active-trace";

        Map<String, Object> details = Map.of(
            "accountRef", accountRef,
            "newLimit", newLimit.toPlainString(),
            "traceId", traceId,
            "controlId", "ACCESS-014"
        );
        publisher.publishEvent(
            new AuditApplicationEvent(operatorId, "PAYMENT_LIMIT_OVERRIDE", details));
    }
}

Two choices in that snippet matter more than the syntax. accountRef is an opaque reference, never an IBAN or a name, because audit logs get copied into tools and tickets, and personal data in them becomes a separate compliance problem. And controlId links the event back to the register, the same trick as the Kyverno annotation.

Back the AuditEventRepository with durable, append-only storage; the default in-memory one forgets everything on restart. Ship events to storage with retention locks. Traces flow through OpenTelemetry to your backend, and my post on distributed tracing with OpenTelemetry in Spring Boot covers the propagation details, while the observability guardrails post covers what to redact before telemetry leaves the process.

Both regimes also put clocks on incident reporting. As I read them, DORA's major-incident flow has an initial notification, an intermediate report and a final report, and NIS2 has an early warning, an incident notification and a final report, all with short deadlines. Confirm exact deadlines and classification criteria with your compliance team. The engineering contribution is modest but real: timestamps. The moment an alert is classified as a potential major incident has to be recorded automatically, since every deadline counts from a moment someone will later need to prove.

Backups: an untested backup is a rumour

Almost every team has backups. Far fewer know how long a restore takes or how much data it would cost them. Recovery time objective (RTO) is how long you can be down, and recovery point objective (RPO) is how much data you can lose. The business agrees to both numbers, and engineering should measure them rather than assert them.

I schedule a restore drill against a scratch database that fails the job if either number misses its target:

@Component
class RestoreDrillRunner {

    private static final Duration RTO_TARGET = Duration.ofMinutes(45);
    private static final Duration RPO_TARGET = Duration.ofMinutes(5);

    private final BackupCatalog catalog;
    private final JdbcTemplate scratchDb;
    private final DrillReportWriter reportWriter;

    RestoreDrillResult runDrill() throws IOException, InterruptedException {
        Instant drillStart = Instant.now();
        BackupSet latest = catalog.latestVerifiedBackup();

        Process restore = new ProcessBuilder(
            "pg_restore", "--clean", "--if-exists", "--no-owner",
            "--dbname", scratchDb.getDataSource().getConnection().getMetaData().getURL(),
            latest.location().toString())
            .inheritIO()
            .start();
        if (restore.waitFor() != 0) {
            throw new IllegalStateException("pg_restore failed for backup " + latest.id());
        }

        Instant newestRestoredRow = scratchDb.queryForObject(
            "SELECT max(created_at) FROM payment_ledger", Instant.class);
        Instant liveCutoff = latest.captureCompletedAt();

        Duration measuredRto = Duration.between(drillStart, Instant.now());
        Duration measuredRpo = Duration.between(newestRestoredRow, liveCutoff.plus(catalog.walLagFor(latest)));

        RestoreDrillResult result = new RestoreDrillResult(
            latest.id(), measuredRto, measuredRpo,
            measuredRto.compareTo(RTO_TARGET) <= 0,
            measuredRpo.compareTo(RPO_TARGET) <= 0);
        reportWriter.write(result);
        return result;
    }
}

Be clear about what that number represents. It times the restore step on a database sized like production. A real recovery also includes failover, DNS, cache warm-up and verification, so add those to the drill as you automate them. Use representative data volume, because restoring an empty schema proves nothing. Reports go to the same evidence store as everything else, and a missed target opens a ticket. Pair this with the discipline from zero-downtime database migrations with Flyway, because a restore that lands on a schema your current code cannot read counts as a failed drill too.

The pipeline: evidence on every deploy

Now the pieces connect. This condensed GitHub Actions job shows the shape; adapt it to your own CI:

jobs:
  build-and-evidence:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - name: Build, test, generate SBOM
        run: ./mvnw -B verify org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
      - name: Build and push image
        run: |
          docker build -t "$IMAGE:$GITHUB_SHA" .
          docker push "$IMAGE:$GITHUB_SHA"
          echo "DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' $IMAGE:$GITHUB_SHA)" >> "$GITHUB_ENV"
      - name: Sign image and attach SBOM attestation
        run: |
          cosign sign --yes "$DIGEST"
          cosign attest --yes --type cyclonedx --predicate target/bom.json "$DIGEST"
      - name: Vulnerability scan (fails on critical)
        run: grype sbom:target/bom.json --fail-on critical -o json > evidence/vuln-scan.json
      - name: Admission policy dry run
        run: kyverno test policies/ --detailed-results > evidence/policy-test.txt
      - name: Resilience drill
        run: ./mvnw -B -Pdrill verify -Ddrill.output=evidence/resilience-drill.json
      - name: Bundle and store evidence
        run: |
          tar czf "evidence-$GITHUB_SHA.tgz" evidence/ target/bom.json
          aws s3 cp "evidence-$GITHUB_SHA.tgz" "s3://$EVIDENCE_BUCKET/$GITHUB_SHA/"

"Immutable bucket" is easy to say and easy to get wrong, so here is what I would specify. Enable S3 Object Lock in compliance mode, not governance mode, because governance mode lets a privileged user lift the lock. Set the retention period to whatever your record-keeping obligations require; that number comes from compliance, not from engineering. Give the CI role write-only access, give a small audit role read access, and let nobody hold delete rights. Link each object to its deploy by keying it on the commit SHA and recording the image digest inside the bundle, so one path leads from a running pod back to the evidence. The restore drill runs on its own schedule and writes to the same bucket under a dated prefix.

Last comes the unglamorous piece that decides whether any of this works: a single page indexing controls to evidence. Control ID, requirement area, mechanism, artifact location, owner. If a control has no row pointing at a bucket path, treat that as a defect and put it on the backlog.

A RACI that fits in sprint planning

Compliance RACIs usually fail by being drawn for an org chart instead of a sprint. This one is drawn for a sprint board, one line per type of work:

Work itemResponsibleAccountableConsultedInformed
Admission policy changePlatform engineerPlatform leadSecurity, service teamsCompliance
Policy exception approvalPlatform engineerPlatform leadService owner, securityCompliance
Service audit events and trace fieldsBackend engineer on the serviceService tech leadSecurity, data protectionCompliance
Resilience drill scenariosBackend engineer, SREEngineering managerBusiness owner of the payment flowRisk
Restore drill and RTO/RPO targetsSRE, DBAHead of engineeringBusiness owner, complianceRisk, audit
Asset register and criticality tiersService ownersHead of engineeringRisk, complianceAudit
Control register and evidence indexCompliance engineerCompliance leadPlatform, backendAudit
Interpretation of regulatory textLegal and complianceChief compliance officerEngineeringEveryone

The last row matters most. Engineers implement the control as written by the people who interpret the regulation; they do not interpret it themselves. The sprint rule I push is that every control change is a ticket whose acceptance criterion is phrased as evidence: "done" means the pipeline produces artifact Y and the index row points at it, not "we configured it."

Start here this week

Pick one service in your payments namespace and do three things before the next sprint review. Write a single Kyverno or Gatekeeper policy that checks image signatures, and run it in audit mode. Add the CycloneDX plugin and store the SBOM against the deploy commit. Create the control index with exactly two rows, each pointing at a real bucket path. Two controls with genuine evidence are worth more than forty in a spreadsheet, and the third one gets easier. For regulated integrations seen from the code level, my PSD2 API development notes are a useful companion, and if you want to compare setups, my details are in the contact section.

Related Articles