Skip to content

Repository files navigation

WindowsAD — Samba 4.22 Active Directory Domain Controller in Docker (Debian 13)

A containerised AD Domain Controller that Windows 11 machines can join: DNS SRV bootstrap, Kerberos (AES), LDAP/LDAPS, Global Catalog, SYSVOL/NETLOGON, secure channel, Group Policy. Built on Debian 13 samba-ad-dc 4.22.11 — one samba process serves the whole DC role.

/dockers/WindowsAD
├── image/
│   ├── Dockerfile               debian:trixie-slim + samba-ad-dc (version-pinned ARG)
│   ├── ad-entrypoint.sh         idempotent: validate -> provision|reuse -> harden -> start
│   └── ad-healthcheck.sh        credential-free health probe
├── docker-compose.yml           production: host networking, primary + `replica` profile
├── docker-compose.lab.yml       isolated TESTLAB.LOCAL lab on an internal network
├── docs/docker-compose.macvlan.yml.example   LAN-addressed DC without host networking
├── .env.example                 every knob, documented
├── scripts/
│   ├── mksecrets.sh             secrets/ad_admin.password (0600, random)
│   ├── tls.sh                   internal CA + LDAPS leaf (SAN required by Win11)
│   ├── verify.sh                full verification battery (add --full for dbcheck)
│   ├── backup.sh / restore.sh   online domain backup + DR drill
│   ├── add-replica.sh           second DC
│   └── win11-join.ps1          Windows-side: DNS -> CA -> join -> verify
├── tests/smoke.sh               end-to-end: build -> provision -> verify -> restart -> verify
└── Makefile                     make            (self-documenting)

How it behaves

ad-entrypoint.sh is idempotent — restart it as often as you like:

  1. validate realm / hostname / NetBIOS / password complexity, fail fast
  2. resolve own IPv4 (AD_IP, or auto-detect) and write /etc/hosts
  3. render /etc/krb5.conf with explicit KDCs (dns_lookup_kdc = false) and udp_preference_limit = 1465 (Win11 + big PACs)
  4. if sam.ldb missing → provision a new forest; if AD_JOIN_SERVER set → join as a replica; otherwise → reuse the existing directory (never re-provisions)
  5. upsert hardening params into smb.conf [global] on every start (so .env edits apply on restart), then refuse to start if testparm rejects anything
  6. self-referencing resolver: container's resolv.conf → nameserver 127.0.0.1 (upstream goes out via dns forwarder); in host mode the host's files are untouched
  7. background samba_dnsupdate every 5 min keeps A records aligned with the current IP
  8. exec samba --foreground --no-process-group — same invocation as the Debian unit, so docker stop shuts the directory down cleanly

Quickstart

cd /dockers/WindowsAD
cp .env.example .env && chmod 600 .env      # EDIT realm/hostname/IP first
make secrets                                # random admin password -> secrets/
make build
make up                                     # host networking: see port checklist below
make verify

Then make backup before you let any Windows machine near it.

First, check nothing owns the DC's ports on the host

ss -ltnup | awk '$5 ~ /:(53|88|135|139|389|445|464|636|3268|3269)$/ {print}'
systemctl is-active systemd-resolved      # must be inactive (or the :53 stub disabled)

systemd-resolved's stub on 127.0.0.53:53 is the classic collision. Disable it, or run the DC in macvlan mode.

Configure .env correctly (the three that bite people)

var why it matters
AD_REALM uppercase, no underscores, a subdomain you control (ad.example.com). Never your mail domain root.
AD_IP goes into the DC's DNS A record. Must be the address clients can actually route to. Get a DHCP reservation — a lease change silently rots every record.
AD_DNS_FORWARDER the DC answers its own zone and forwards everything else.

Windows 11 join

# elevated PowerShell on the Win11 box (copy scripts/tls.sh output ca.crt over first)
Set-Location \\server\dockers\WindowsAD   # or copy the script locally
.\win11-join.ps1 -DnsServer 192.168.5.12 -Domain ad.example.com `
                 -DcHost dc1.ad.example.com -CaCert C:\adca.crt

Or manually:

Set-DnsClientServerAddress -InterfaceIndex (Get-NetAdapter).ifIndex -ServerAddresses 192.168.5.12
Resolve-DnsName _ldap._tcp.dc._msdcs.ad.example.com -Type SRV     # must answer
Add-Computer -DomainName ad.example.com -Restart
# after reboot
Test-ComputerSecureChannel -Verbose ; nltest /dsgetdc:ad ; gpupdate /force ; gpresult /r

RSAT (to author GPOs/OU structure against Samba):

Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0
Add-WindowsCapability -Online -Name Rsat.GroupPolicy.Management.Tools~~~~0.0.1.0

Hardening map (.env → smb.conf)

Windows 11 requirement this project
SMB1 is gone AD_SMB_MIN_PROTOCOL=SMB2_02
sign or encrypt always / 24H2 refuses unsigned channels AD_SMB_SIGNING=required
SMB3 session encryption AD_SMB_ENCRYPT=desired → required once all clients support it
NTLM retirement AD_NTLM_AUTH=ntlmv2-only → disabled after you audit for NTLM users
no anonymous enumeration AD_RESTRICT_ANONYMOUS=2, map to guest = never
AES Kerberos, PAC > 1465 bytes udp_preference_limit = 1465 in rendered krb5.conf
Schannel needs SAN, not CN scripts/tls.sh issues SAN=DNS:<dc fqdn>,…
LDAP signing push the GPO “Domain controller: LDAP server signing requirements = Require signing”; serve LDAPS 636 with a trusted cert so clients never need the unsigned path
time skew < 5 min host must run chrony/ntp; joined clients sync from the DC (MS-SNTP served by samba)

LDAPS and the trust chain

make tls           # internal CA + leaf with SAN, then enable in .env
# add to .env:
#   AD_TLS_CERT=/run/ad-secrets/tls/dc.crt
#   AD_TLS_KEY=/run/ad-secrets/tls/dc.key
#   AD_TLS_CA=/run/ad-secrets/tls/ca.crt
docker compose up -d --force-recreate ad-dc
./scripts/verify.sh          # chain, SAN, strict bind + two rejection tests

make tls also publishes the CA where joined machines can fetch it: \\<domain>\NETLOGON\ad-root-ca.crt (NETLOGON is read-only and readable by any domain credential — the same place Windows ships its own CA). If that extra file disturbs the SYSVOL NT ACLs, the script detects it via sysvolcheck and rolls the copy back.

Trust it on each Windows client (this is the step that removes the popups):

certutil -addstore -f ROOT C:\path\to\ca.crt
certutil -addstore -f ROOT "\\premier.local\NETLOGON\ad-root-ca.crt"   # or straight off the DC

Install into the LOCAL COMPUTER store, not the current-user store: LDAP/Schannel binds run as SYSTEM and service accounts, which cannot see per-user trust. Fleet-wide: GPO → Computer Configuration → Policies → Windows Settings → Security Settings → Public Key Policies → Trusted Root Certification Authorities.

The entrypoint stages the mounted cert/key into /var/lib/samba/private/tls/ as root with mode 0600, because Samba refuses to load a key that is not root-owned (CVE-2013-4476) — which is exactly what a bind-mounted host cert looks like from inside the container. It also checks the key matches the cert and that the SAN contains the DC FQDN, failing fast otherwise. So the host-side ownership of secrets/ does not matter.

Buy a public cert (EasySSL / Let's Encrypt) instead? Mostly no.

For a domain-joined machine an internal CA gives you strictly fewer popups, not more: once the root sits in the machine trust store the certificate is unremarkable, and you never touch it again for ~2 years (make tls re-issues against the same CA).

internal CA (make tls) public CA (EasySSL / LE)
works for .local, .lan, or a private name yes no — requires a publicly resolvable, validatable hostname
popups on joined Win11 clients none once the root is trusted none once the chain validates
renewal ~2.3 yr leaf, 10 yr CA, re-issue in place 90 days (LE) / 1 yr, automation mandatory; expiry = LDAPS/app outage
where the key lives your secrets/tls/ fine, but you must automate fetching it into the container
information leak none hostname (and any SAN) is published in Certificate Transparency logs
LDAP signing / channel binding unaffected either way unaffected either way

Use a public cert when a non-domain-joined or external client must reach LDAPS/HTTPS and you cannot install a root there (SaaS connectors, hosted apps, appliances). For the LAN and domain-joined Win11 boxes, the internal CA is the right tool.

Trust-anchor hygiene: secrets/tls/ca.key is the thing to guard. Back it up offline and encrypted — lose it and every cert it signed must be reissued and re-trusted; leak it and an attacker can mint certificates your whole fleet trusts. Rotating the leaf is cheap (make tls, CA untouched); rotating the CA means redeploying trust everywhere.

Ops

make verify                    # ~15 checks; exits non-zero on failure
make verify FULL=1             # + samba-tool dbcheck --cross-ncs (slow, DB heavy)
make backup                    # online consistent backup -> ./backups/<ts>.tgz + sha256
make restore BACKUP=backups/<ts>.tgz   # DR drill into a fresh data dir (asks RESTORE)
make fsmo                      # FSMO ownership
make repl-status               # samba-tool drs showrepl
make passwd                    # rotate administrator
make shell                     # docker exec -it ad-dc bash

Rotate the backup to off-host storage and practise the restore. scripts/restore.sh preserves the previous data dir instead of deleting it.

Two DCs: scripts/add-replica.sh (run it on a second machine — host networking means two DCs on one host fight over 53/88/445; use the macvlan variant for one host).

Testing

make smoke    # isolated TESTLAB.LOCAL: build -> provision -> verify -> restart -> verify
make drill    # backup -> restore -> domain SID + planted OU must survive
make ldaps    # LDAPS chain verifies, SAN matches, untrusted clients rejected
make test     # all three (run before every upgrade)

What each suite asserts beyond "it came up":

suite the checks that actually matter
tests/smoke.sh fresh forest serves DNS SRV + Kerberos + LDAP/GC + SYSVOL; the hardened smb.conf parameters really landed; a restart reuses the database instead of re-provisioning; anonymous LDAP enumeration and SMB1 are refused
tests/dr-drill.sh plants an OU, backs up, restores into a fresh volume as a new DC, then asserts the restored DC has the same domain SID and still contains the planted OU, and never ran a fresh provisioning. A restore that looks healthy but lost your data is the worst failure mode, so this is the important one.
tests/ldaps-test.sh 636 verifies against the generated CA, SAN contains the DC FQDN, and a client without the CA is rejected

The lab is fully isolated: internal: true docker network, throwaway TESTLAB.LOCAL realm, no route to your LAN, named volumes. make labdown destroys it.

Validated here (Debian 13.6, Docker 29.7.2, Samba 4.22.11)

  • smoke: 25 passed / 0 failed on a fresh forest, and again after a container restart (log-verified that the restart reused the database)
  • drill: restored DC presented the same domain SID as the source and still held the planted DR-MARKER-* OU; no fresh provisioning occurred
  • ldaps: 26 passed / 0 failed, chain verified against the generated CA, untrusted handshake rejected

Gotchas discovered and handled the hard way (all now encoded in the entrypoint/tests):

  1. samba-ad-provision is a separate Debian package — without it provisioning can't find the AD schema.
  2. Provisioning dies on SYSVOL NT-ACL writes unless the container has CAP_SYS_ADMIN.
  3. Pre-creating smb.conf before provisioning makes samba-tool domain provision refuse (no [netlogon] share) — pass the realm with --option="realm=..." instead.
  4. Samba's backup archives are .tar.bz2, not .tgz.
  5. samba-tool domain backup restore rejects a non-empty target dir, so the image keeps /var/lib/samba empty; and it restores as a new DC name.
  6. Samba refuses a TLS key that isn't root-owned (CVE-2013-4476) — hence the cert staging step.
  7. libsasl2-modules-gssapi-mit is required for ldapsearch -Y GSSAPI inside the image.
  8. Under set -o pipefail, cmd | head -1 turns a normal SIGPIPE into a fatal error.

Disaster recovery — read this before you need it

samba-tool domain backup restore is restore-as-a-new-domain-controller: it unpacks the database and then joins the target host into it. Consequences:

  • You cannot restore under the same hostname the backup came from — that computer account already exists in the restored DB (Entry CN=... already exists). Use a new name and a free IP: ./scripts/restore.sh backups/<archive>.tar.bz2 dc3 192.168.5.55
  • restore.sh saves .env, moves the live data/samba aside (timestamped, never deleted), rewrites AD_HOSTNAME/AD_IP, restores, starts, verifies, and prints rollback commands on failure.
  • Afterwards point DHCP/DNS at the new DC and clean up the dead DC's stale computer/DNS objects (the script prints the commands).
  • The restore target must be empty, so the image deliberately leaves /var/lib/samba empty — otherwise a fresh named volume gets seeded with content and the restore refuses to run.
  • Archives are samba-backup-<domain>-<ts>.tar.bz2 + .sha256 and contain the domain secrets. Store them encrypted, off-host, and practise the restore on a schedule.

Network modes

mode how Win11 clients on the LAN notes
host (default) network_mode: host ✅ host IP == DC IP; needs 53/88/135/445/3268 free
macvlan docs/docker-compose.macvlan.yml.example ✅ own LAN IP; container↔host blocked by default
bridge/lab docker-compose.lab.yml ❌ isolated; internal-only or port-published for local experiments

Port publishing does not work for real joins: the DC advertises its own (unroutable) address in DNS and DCE-RPC allocates dynamic ports. That is why host/macvlan exist.

Limitations — read before production

  1. Homogeneous DC topology. Samba dropped FRS. Do not mix a Windows Server DC into a Samba domain (or vice versa) for SYSVOL replication. Samba↔Samba replicas only.
  2. No AD CS — no enterprise CA, no certificate auto-enrollment, no AD-integrated PKI. LDAPS comes from scripts/tls.sh, ACME DNS-01, or your existing PKI.
  3. No Entra ID hybrid. Azure AD Connect is unsupported against Samba. If you need M365 SSO, Entra ID should be the authority and this box plays a different role.
  4. Don't run adprep from newer Windows media against the Samba-provisioned schema.
  5. GPO authoring works from RSAT/GPMC, but validate each client-side extension you rely on; some newer policy extensions call AD features Samba doesn't implement.
  6. Heavy AD-coupled products (Exchange, SCCM/MECM, AD-joined PKI flows) are off the table.
  7. LDAPS is server authentication only. No NTAuth store, no certificate-based client authentication, and Samba does not implement LDAP channel binding (GS2-TBS). Windows only demands channel binding when the DC-side policy is set to "Always" — a policy Samba does not implement — so clients fall back and joins work; just don't point clients at this DC if they are configured to require CB unconditionally. SASL LDAP signing/sealing is supported.
  8. Do not use .local for the realm. RFC 6762 reserves it for multicast DNS, so macOS, iOS, Android and Linux hosts with avahi/systemd-resolved will never send those names to your DNS server. Use a subdomain you own (ad.example.com), or .lan / .internal / home.arpa (RFC 8375). A single-label realm (just PREMIER) is worse again: SRV and Kerberos locator lookups depend on a real DNS suffix.

Upgrades

SAMBA_VERSION is pinned in docker-compose.yml and the Dockerfile ARG. Upgrade in a controlled way:

  1. check Samba's supported upgrade-path matrix (e.g. 4.22 → 4.24 is a supported step)
  2. make backup and verify the archive restores
  3. change the pin, make build, make up
  4. inside the container: samba_upgradedns, then samba-tool dbcheck --cross-ncs, samba-tool ntacl sysvolcheck, make verify
  5. upgrades inside a running container image are not automatic — a rebuild is deliberate

Debian 13 also offers samba 4.24 in trixie-backports if you want the newer line; do the same drill with SAMBA_VERSION=2:4.24.5+dfsg-1~bpo13+1.

Security notes

  • secrets/ is 0600 and mounted read-only at /run/ad-secrets; never baked into the image (.dockerignore blocks it).
  • During the first provision Samba passes the admin password via argv, so docker top can see it for a few seconds. If that matters, rotate immediately: make passwd.
  • Container runs cap_drop: ALL plus the capabilities Samba actually needs. SYS_ADMIN is required: Samba persists NT ACLs in the security.NTACL xattr and the kernel refuses that inside a container without it (provisioning dies writing SYSVOL ACLs). If you must avoid SYS_ADMIN, switch SYSVOL/NETLOGON to the acl_xattr VFS module (vfs objects = acl_xattr, acl_xattr:xattr_names = user.NTACL user.DOSATTRIB, acl_xattr:ignore system acls = yes, map acl inherit = yes, store dos attributes = yes) in conf/smb.conf and set AD_APPLY_HARDENING=no — supported but a non-standard ACL backend; validate GPO application carefully before trusting it.
  • The healthcheck is credential-free by design so it can never lock out an account.
  • The lab compose file contains a throwaway password on purpose (TESTLAB.LOCAL, no route to any network). Do not copy those values into .env.

About

A containerised AD Domain Controller that Windows 11 machines can join: DNS SRV bootstrap, Kerberos (AES), LDAP/LDAPS, Global Catalog, SYSVOL/NETLOGON, secure channel, Group Policy. Built on Debian 13 samba-ad-dc 4.22.11 — one samba process serves the whole DC role.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages