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)
ad-entrypoint.sh is idempotent — restart it as often as you like:
- validate realm / hostname / NetBIOS / password complexity, fail fast
- resolve own IPv4 (
AD_IP, or auto-detect) and write/etc/hosts - render
/etc/krb5.confwith explicit KDCs (dns_lookup_kdc = false) andudp_preference_limit = 1465(Win11 + big PACs) - if
sam.ldbmissing → provision a new forest; ifAD_JOIN_SERVERset → join as a replica; otherwise → reuse the existing directory (never re-provisions) - upsert hardening params into
smb.conf [global]on every start (so.envedits apply on restart), then refuse to start iftestparmrejects anything - self-referencing resolver: container's
resolv.conf→nameserver 127.0.0.1(upstream goes out viadns forwarder); in host mode the host's files are untouched - background
samba_dnsupdateevery 5 min keeps A records aligned with the current IP exec samba --foreground --no-process-group— same invocation as the Debian unit, sodocker stopshuts the directory down cleanly
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 verifyThen make backup before you let any Windows machine near it.
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.
| 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. |
# 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.crtOr 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 /rRSAT (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| 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) |
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 testsmake 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 DCInstall 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.
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.
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 bashRotate 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).
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.
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 plantedDR-MARKER-*OU; no fresh provisioning occurredldaps: 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):
samba-ad-provisionis a separate Debian package — without it provisioning can't find the AD schema.- Provisioning dies on SYSVOL NT-ACL writes unless the container has
CAP_SYS_ADMIN. - Pre-creating
smb.confbefore provisioning makessamba-tool domain provisionrefuse (no[netlogon]share) — pass the realm with--option="realm=..."instead. - Samba's backup archives are
.tar.bz2, not.tgz. samba-tool domain backup restorerejects a non-empty target dir, so the image keeps/var/lib/sambaempty; and it restores as a new DC name.- Samba refuses a TLS key that isn't root-owned (CVE-2013-4476) — hence the cert staging step.
libsasl2-modules-gssapi-mitis required forldapsearch -Y GSSAPIinside the image.- Under
set -o pipefail,cmd | head -1turns a normal SIGPIPE into a fatal error.
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.shsaves.env, moves the livedata/sambaaside (timestamped, never deleted), rewritesAD_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/sambaempty — otherwise a fresh named volume gets seeded with content and the restore refuses to run. - Archives are
samba-backup-<domain>-<ts>.tar.bz2+.sha256and contain the domain secrets. Store them encrypted, off-host, and practise the restore on a schedule.
| 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.
- 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.
- 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. - 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.
- Don't run
adprepfrom newer Windows media against the Samba-provisioned schema. - 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.
- Heavy AD-coupled products (Exchange, SCCM/MECM, AD-joined PKI flows) are off the table.
- 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.
- Do not use
.localfor 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 (justPREMIER) is worse again: SRV and Kerberos locator lookups depend on a real DNS suffix.
SAMBA_VERSION is pinned in docker-compose.yml and the Dockerfile ARG. Upgrade in a
controlled way:
- check Samba's supported upgrade-path matrix (e.g. 4.22 → 4.24 is a supported step)
make backupand verify the archive restores- change the pin,
make build,make up - inside the container:
samba_upgradedns, thensamba-tool dbcheck --cross-ncs,samba-tool ntacl sysvolcheck,make verify - 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.
secrets/is0600and mounted read-only at/run/ad-secrets; never baked into the image (.dockerignoreblocks it).- During the first provision Samba passes the admin password via
argv, sodocker topcan see it for a few seconds. If that matters, rotate immediately:make passwd. - Container runs
cap_drop: ALLplus the capabilities Samba actually needs.SYS_ADMINis required: Samba persists NT ACLs in thesecurity.NTACLxattr and the kernel refuses that inside a container without it (provisioning dies writing SYSVOL ACLs). If you must avoidSYS_ADMIN, switch SYSVOL/NETLOGON to theacl_xattrVFS 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) inconf/smb.confand setAD_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.