I wanted a single-user, private HTTPS site that only lets me in—no passwords, no TOTP, just my Romanian eID (CEI) card. The plan: use the card’s client certificate for mTLS and have Nginx only serve content when it sees my cert.
This post is a practical, copy-paste log of what I did, the exact errors I hit, and how I fixed them. It’s written in the same “get to the point + show the commands” style I used in my Reverse SSH Tunnel Tutorial — just applied to PKI this time.
What’s on the Romanian eID?
On Linux, listing the token with pkcs11-tool -O showed: My Authentication certificate (ECC P-384). The issuing RO CEI MAI Sub-CA. The RO CEI MAI Root-CA.
That’s enough to build the trust chain locally even if public CA URLs are flaky. (Paper note: we’ll export these from the card and feed them to Nginx.)
Exporting the Sub-CA and Root-CA
You can export by ID or by pkcs11: URI (I had fewer surprises with p11tool):
PROV=/usr/lib/idplugclassic/libidplug-pkcs11.so
p11tool --provider="$PROV" --list-all # discover URIs
# If you only have the hex ID, percent-encode it:
hex_id_sub=3f6b16cd16e71232dc0cfa1f3c600ca453d47fe9
pct_id_sub=$(echo "$hex_id_sub" | sed 's/../%&/g')
# Export Sub-CA and Root-CA (DER)
p11tool --provider="$PROV" \
--export "pkcs11:model=ID-A;manufacturer=DGEP;token=PKI%20Application%20%28User%20PIN%29;id=$pct_id_sub;type=cert" \
> ro_cei_mai_sub-ca.der
hex_id_root=e63c8507b35553a635e37da34d98e592d6b4ceed
pct_id_root=$(echo "$hex_id_root" | sed 's/../%&/g')
p11tool --provider="$PROV" \
--export "pkcs11:model=ID-A;manufacturer=DGEP;token=PKI%20Application%20%28User%20PIN%29;id=$pct_id_root;type=cert" \
> ro_cei_mai_root-ca.der
# Convert to PEM and bundle
openssl x509 -inform der -in ro_cei_mai_sub-ca.der -out ro_cei_mai_sub-ca.pem
openssl x509 -inform der -in ro_cei_mai_root-ca.der -out ro_cei_mai_root-ca.pem
cat ro_cei_mai_sub-ca.pem ro_cei_mai_root-ca.pem > cei-mai-ca-bundle.pem
I placed the bundle here: /home/user/mtls-cei/cei/cei-mai-ca-bundle.pem
Self-signed server TLS
I didn’t want Let’s Encrypt/port 80. So I generated a self-signed server cert:
mkdir -p /srv/mtls/server
openssl ecparam -genkey -name prime256v1 -out /srv/mtls/server/server.key
openssl req -new -x509 -days 1825 -key /srv/mtls/server/server.key \
-out /srv/mtls/server/server.crt -subj "/CN=mtls.local"
Dockerized Nginx for mTLS
Folder layout on the host:
/home/user/mtls-cei/
mtls.conf
index.html
cei/
server.crt
server.key
cei-mai-ca-bundle.pem
docker-compose.yml service:
services:
mtls-cei:
image: nginx:latest
container_name: mtls-cei
restart: always
networks: [ main_net ]
ports:
- "9033:443/tcp" # external 9033 -> container 443
volumes:
- "/home/user/mtls-cei/:/usr/share/nginx/html:ro"
- "/home/user/mtls-cei/mtls.conf:/etc/nginx/conf.d/mtls.conf:ro"
- "/home/user/mtls-cei/cei:/etc/nginx/cei:ro"
/home/user/mtls-cei/mtls.conf:
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/cei/server.crt;
ssl_certificate_key /etc/nginx/cei/server.key;
# trust the issuing Sub-CA + Root-CA (from the card)
ssl_client_certificate /etc/nginx/cei/cei-mai-ca-bundle.pem;
# important while testing: request the cert but don't hard-fail yet
ssl_verify_client optional;
ssl_verify_depth 3;
# block requests that didn't present a valid client cert
if ($ssl_client_verify != SUCCESS) { return 403; }
# allow only my card (Subject DN serialNumber from my eID)
if ($ssl_client_s_dn !~ "serialNumber=0101010101010101") { return 403; }
# debug endpoint: prints what the server saw
location = /whoami {
add_header Content-Type text/plain;
return 200 "DN: $ssl_client_s_dn\nFP: $ssl_client_fingerprint\nVerify: $ssl_client_verify\n";
}
location / {
root /usr/share/nginx/html;
index index.html;
}
}
Bring the service up:
docker compose up -d --force-recreate mtls-cei
docker logs -f mtls-cei
Now I can visit: https://<server-ip>:9033/ and my browser should ask for a client certificate.