Implementing NIS2 technical measures in a mid-sized software company
A practical guide for software teams translating NIS2 into concrete engineering controls. It focuses on the technical measures you can actually deploy, how they fit into your architecture, and the trade-offs that matter when you need to make implementation decisions rather than collect policy PDFs.
TL;DR — NIS2 is not a single tool; for a mid-sized software company it usually becomes a minimum technical baseline across identity, endpoints, networks, logging, backups, vulnerability handling, and incident response. The single most useful move is to define one enforceable control set for all production systems: SSO+MFA, hardened admin access, centralized logs, tested backups, patch/vulnerability SLAs, and network segmentation wired into CI/CD and infrastructure code. Reading time: ~7 min
What it is and where it sits
For an engineering team, NIS2 technical measures are the controls you can point to when someone asks: "How do we reduce the chance that a compromised laptop, leaked token, unpatched VM, or broken backup turns into a reportable incident?" The directive is broad; your implementation should not be. Treat it as a cross-cutting security baseline applied to the systems that build, deploy, run, and support your product.
In a mid-sized software company, these measures usually sit in five places:
- Identity plane: your IdP, SSO, MFA, SCIM provisioning, break-glass accounts.
- Delivery plane: Git hosting, CI runners, artifact registries, IaC pipelines, secret stores.
- Runtime plane: cloud accounts, Kubernetes/VMs, load balancers, WAF/reverse proxies, databases, queues.
- Observability plane: centralized logs, audit trails, alerting, retention, time sync.
- Recovery plane: backups, immutable storage, restore tests, DR runbooks.
What it replaces: ad hoc per-team security decisions, local admin accounts, manually rotated credentials, "best effort" patching, and backups that have never been restored.
What talks to it: almost everything. Your app traffic still flows app-to-db, but NIS2 controls wrap around that path: who can deploy, who can SSH, how secrets are injected, where audit logs go, how incidents are detected, and how recovery is proven.
View as text
[Developer Laptop] ---- 1. git push / kubectl ----> [IdP + MFA] [IdP + MFA] ---- 2. short-lived auth ----> [CI/CD] [CI/CD] ---- 3. deploy with signed artifact ----> [Runtime] [Runtime] ---- 4. app logs/audit ----> [Central Logs] [Runtime] ---- 5. snapshots / WAL / dumps ----> [Backup Store] [Security Team] <---- 6. alerts / queries ---- [Central Logs] [Ops Team] <---- 7. restore test ---- [Backup Store]
Architecture context matters because NIS2 evidence usually comes from control points, not business logic. If your app is perfect but your CI runner has a long-lived cloud key in plaintext, you still fail the practical test.
How it actually works
Take one realistic example: a developer laptop is compromised, and the attacker tries to use cached credentials to reach production and exfiltrate customer data. Your NIS2 implementation should turn that from "silent breach" into "blocked, logged, recoverable incident."
Step-by-step flow
- The developer signs in through SSO with MFA to Git, cloud, VPN, and internal admin tools. No local passwords on production systems. Admin roles require phishing-resistant MFA if your stack supports it; otherwise TOTP is the floor, not the target.
- Provisioning is group-based. When the developer leaves the on-call or platform group, SCIM or equivalent removes access automatically. No manual cleanup tickets lingering for weeks.
- CI/CD uses workload identity or short-lived tokens, not static cloud keys in repo secrets. The build system gets a token scoped to one deploy action for minutes, not a generic admin credential valid for a year.
- Production network paths are segmented. App nodes can reach the database on
5432; developer laptops cannot. Bastion or VPN access is restricted to a small admin group and logged. - Central logs collect auth, admin, deploy, and database audit events. Time is synchronized with NTP so incident timelines are usable.
- Endpoint or cloud detection raises an alert when the compromised laptop attempts unusual access, or when the attacker tries to create a new API key / disable logging / snapshot a database.
- Backups are isolated and tested. If the attacker gets as far as deleting data, you can restore to a known point and prove the restore works.
Concretely, the mechanism is layered denial plus evidence:
- The stolen laptop session alone is not enough because admin access requires MFA and short-lived credentials.
- Even if Git is reached, branch protection and CI permissions limit what can be deployed.
- Even if a workload is compromised, network policy limits lateral movement.
- Every meaningful action emits logs to a separate system the attacker cannot easily tamper with.
- Recovery does not depend on the production account still being trustworthy.
A good implementation is visible in command output. For example, when checking whether a service is exposing weak transport or missing redirect/HSTS, the output should be boring and explicit:
curl -I http://app.example.com
HTTP/1.1 301 Moved Permanently
Location: https://app.example.com/
Server: nginx
Then:
curl -I https://app.example.com
HTTP/2 200
server: nginx
strict-transport-security: max-age=31536000; includeSubDomains
content-security-policy: default-src 'self'
A misconfigured edge often looks like this instead:
HTTP/1.1 200 OK
Server: nginx
No redirect on port 80, no Strict-Transport-Security, and often no proxy header normalization. That is not "paperwork incomplete"; it is a concrete missing control.
When to use it (and when not to)
You use this approach when you need a company-wide, auditable technical baseline that survives staff changes and scales across teams. You do not start with a giant compliance project plan and 40 bespoke controls if you are still missing MFA on production access.
| Scenario | Recommendation |
|---|---|
| You run customer-facing SaaS with cloud infrastructure, CI/CD, managed databases, and 50-500 staff | Implement a standard control baseline in identity, logging, backups, vulnerability handling, and admin access first |
| You already have ISO 27001/SOC 2 controls but they are policy-heavy and tool-light | Map existing controls to actual technical enforcement points; remove manual exceptions where possible |
| You are a 6-person startup with one product, one cloud account, and no regulated customers yet | You probably don't need a full NIS2 program structure yet, but you still need the same core controls: MFA, backups, patching, logging |
| You want to buy a single "NIS2 platform" to solve this | Don't. Use existing identity, cloud, logging, and backup capabilities first; the gap is usually enforcement and evidence, not missing dashboards |
| Your biggest current risk is unsupported legacy systems you cannot patch | Prioritize isolation, compensating controls, and migration planning over trying to force a modern baseline everywhere immediately |
You probably don't need a separate control for every team or service. Mid-sized companies usually do better with one hardened paved road than with local freedom plus audit pain.
Trade-offs
Every NIS2 technical measure has a real cost. If you ignore the cost, teams route around the control.
- SSO + MFA for all admin access
- Benefit: kills a large class of password reuse and shared-account failures.
- Cost: onboarding friction, break-glass design, occasional lockouts during IdP outages.
- Short-lived credentials instead of static secrets
- Benefit: less secret sprawl, lower blast radius, easier rotation.
- Cost: more IAM complexity, confusing auth failures, tighter coupling to cloud/CI identity features.
- Centralized immutable-ish logging
- Benefit: investigation works; you can detect and prove what happened.
- Cost: ingestion/storage bills, schema drift, noisy alerts, retention decisions.
- Network segmentation / private admin paths
- Benefit: reduces lateral movement and accidental exposure.
- Cost: more DNS/routing complexity, harder local debugging, surprise egress failures.
- Tested backups and restore drills
- Benefit: ransomware and operator mistakes become survivable.
- Cost: storage spend, restore environment overhead, periodic disruptive testing.
- Patch/vulnerability SLAs
- Benefit: known exploitable issues do not linger indefinitely.
- Cost: change windows, dependency churn, occasional regressions from urgent upgrades.
The common trade-off is centralization versus team autonomy. For compliance and incident response, centralization usually wins on admin access, logs, and backups. For application-level controls, let teams implement within a standard contract.
In practice
Example 1: nginx edge hardening for transport and basic headers
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'self'" always;
location / {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
This forces HTTP to HTTPS and adds a minimum header baseline at the reverse proxy. Gotcha: Strict-Transport-Security is sticky in browsers; do not preload or set long durations on a domain until you know every subdomain is HTTPS-capable.
Check and reload with explicit failure handling:
sudo nginx -t && sudo systemctl reload nginx
Typical success output:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Typical failure shape:
2026/10/30 09:14:22 [emerg] 18421#18421: unknown directive "addd_header" in /etc/nginx/sites-enabled/app.conf:14
nginx: configuration file /etc/nginx/nginx.conf test failed
Example 2: PostgreSQL backup + restore verification
⚠️ A restore test can overwrite data if you point it at the wrong database. Run restores into a separate database or isolated instance, and triple-check
PGHOST,PGDATABASE, and credentials before executingdropdborpg_restore --clean.
export PGHOST=db.internal
export PGPORT=5432
export PGUSER=backup_user
export PGPASSWORD='REDACTED'
export PGDATABASE=app_prod
pg_dump -Fc --no-owner --no-privileges -f /backups/app_prod_$(date +%F).dump
sha256sum /backups/app_prod_$(date +%F).dump > /backups/app_prod_$(date +%F).dump.sha256
This creates a compressed custom-format backup and a checksum file. Gotcha: pg_dump is logical backup, not point-in-time recovery; for large or high-write systems you usually also need continuous WAL archiving or storage snapshots.
Restore verification into a scratch database:
createdb app_restore_test
pg_restore --verbose --clean --if-exists --no-owner --no-privileges -d app_restore_test /backups/app_prod_$(date +%F).dump
psql -d app_restore_test -c "select count(*) from users;"
Expected output shape:
pg_restore: connecting to database for restore
pg_restore: dropping TABLE users
pg_restore: creating TABLE users
pg_restore: processing data for table "public.users"
count
-------
48219
(1 row)
If you see pg_restore: error: could not execute query: ERROR: permission denied for schema public, your restore role is too weak for the test target. Fix the role or restore into an isolated instance where you control ownership.
Example 3: CI job failing closed on high-severity vulnerabilities
stages:
- test
vuln_scan:
stage: test
image: aquasec/trivy:latest
script:
- trivy fs --exit-code 1 --severity HIGH,CRITICAL --no-progress .
This makes the pipeline exit non-zero when high/critical findings are present. Gotcha: if you turn this on without a baseline process, old findings will block every merge; start by scanning default branches, triage, then enforce on new changes or after a cleanup window.
Typical failure output shape:
2026-10-30T09:31:44Z INFO [vuln] Vulnerability scanning is enabled
2026-10-30T09:31:52Z HIGH openssl CVE-2026-12345 fixed in 3.0.15-r0
2026-10-30T09:31:52Z CRITICAL glibc CVE-2026-45678 fixed in 2.39-r2
Exit code 1 should fail the job; if your pipeline still passes, check whether the CI wrapper is swallowing non-zero exits with || true or equivalent.
Further reading
- NIS2 Directive text in EUR-Lex
- ENISA guidance on cybersecurity measures and incident reporting
- CIS Critical Security Controls v8
- NIST SP 800-61 Computer Security Incident Handling Guide
- The PostgreSQL documentation sections on Backup and Restore and Continuous Archiving
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI