In a modern DevOps infrastructure, operating an internal Public Key Infrastructure (PKI) is essential to secure communications between services. Instead of relying on self-signed certificates or external certificate authorities, organizations can deploy an internal CA to issue trusted certificates for internal services.
This guide demonstrates how to configure TLS certificates using Smallstep step-ca for our services:
- Nexus (running on Kubernetes)
- Jenkins (running on a VM)
- GitLab (running on a VM)
- Vault (running on a VM)
The architecture separates certificate issuance based on environment:
Step-CA
│
└─ JWK provisioner
│
├─ Nexus (certificate issued manually → Kubernetes TLS Secret)
├─ Jenkins
├─ Vault
└─ GitLab
The internal CA endpoint used throughout this guide:
https://ca.devops-db.internal
Understanding the JWK Provisioner in Step-CA
In this environment, certificate issuance for most services is performed using a JWK provisioner configured in Smallstep step-ca.
A provisioner defines how clients authenticate when requesting certificates from the CA.
Several provisioner types exist, including:
- JWK
- ACME
- OIDC
- X5C
- SSHPOP
- Kubernetes SA
In this setup, a JWK provisioner named vm-hosts is used to issue certificates for services running on virtual machines and for manually generated certificates used in Kubernetes.
The provisioner configuration includes a public key that the CA uses to validate signed tokens.
When requesting a certificate, the Step CLI generates a signed JWT token using the private key corresponding to this JWK. Once generated, the token is used to authenticate the certificate request.
The CA verifies the token signature using the provisioner’s public key. If valid, the certificate is issued.
This mechanism provides:
- cryptographic authentication
- short-lived authorization tokens
- secure certificate issuance without exposing CA credentials
Creating a Certificate for Nexus (Kubernetes)
When a service runs inside Kubernetes, TLS is typically terminated by an Ingress controller. Instead of installing certificates directly in the application container, the certificate is stored in a Kubernetes TLS Secret and referenced by the Ingress configuration.
In this example, the certificate is issued manually using the JWK provisioner and then stored as a Kubernetes TLS Secret.
In larger Kubernetes environments, this process is usually automated using ACME together with cert-manager.
In this example, the Nexus instance will be accessible through:
https://nexus.devops-db.internal
Bootstrap the Step CLI
Before issuing certificates, the CLI must trust the internal CA.
step ca bootstrap \
--ca-url https://ca.devops-db.internal \
--fingerprint b8d4d77badb5aeca4d23a67269846aa3dbf3271c94c0cf97e82e482aab6e7732
This command initializes the local Step CLI configuration and creates:
~/.step/config
~/.step/certs/root_ca.crt
The CLI can now securely communicate with the CA.
Generate a Token Using the JWK Provisioner
The certificate request is authenticated using a JWK provisioner configured in Step-CA.
TOKEN=$(step ca token nexus.devops-db.internal \
--provisioner vm-hosts \
--key jwk-provisioner-pub.json)
This command generates a signed token that authorizes the certificate request.
Issue the Certificate
step ca certificate \
nexus.devops-db.internal \
nexus.devops-db.internal.crt \
nexus.devops-db.internal.key \
--token $TOKEN
This produces two files:
nexus.devops-db.internal.crt
nexus.devops-db.internal.key
Create the Full Certificate Chain
Ingress controllers usually expect the complete certificate chain.
cat nexus.devops-db.internal.crt intermediate-ca.crt > fullchain.crt
The resulting file contains:
- The Nexus server certificate
- The intermediate CA certificate
Create a Kubernetes TLS Secret
The certificate and private key are stored as a Kubernetes TLS secret.
kubectl create secret tls nexus-tls \
--cert=fullchain.crt \
--key=nexus.devops-db.internal.key \
-n default
Verify the secret:
kubectl get secret nexus-tls -n default
Expected output:
NAME TYPE DATA AGE
nexus-tls kubernetes.io/tls 2 17s
Configure the Ingress Resource
The Ingress must reference the TLS secret.
kubectl apply -n default -f - <<EOF
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nexus-ingress
spec:
ingressClassName: public
rules:
- host: nexus.devops-db.internal
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nexus
port:
number: 8081
tls:
- hosts:
- nexus.devops-db.internal
secretName: nexus-tls
EOF
Once applied, the Ingress controller will terminate TLS using the secret.
Verify the Certificate
Confirm that the correct certificate is being served.
echo | openssl s_client -connect nexus.devops-db.internal:443 -servername nexus.devops-db.internal 2>/dev/null | openssl x509 -noout -issuer -subject
Expected output:
issuer=C=PT, O=DevOps-DB, CN=DevOps-DB Intermediate CA
subject=CN=nexus.devops-db.internal
The following steps are required for Jenkins, Vault, and GitLab.
Install the Step CLI
curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg -o /etc/apt/keyrings/smallstep.asccat << EOF > /etc/apt/sources.list.d/smallstep.sources
Types: deb
URIs: https://packages.smallstep.com/stable/debian
Suites: debs
Components: main
Signed-By: /etc/apt/keyrings/smallstep.asc
EOFapt update
apt install step-cli3. Installing the Root CA
The Step-CA server publishes the root certificate.
curl -O -k https://ca.devops-db.internal/roots.pemInspect the certificate:
openssl x509 -in roots.pem -text -nooutTrust the Root CA
To ensure the system trusts certificates issued by the internal CA, install the root certificate into the OS trust store.
sudo mkdir -p /usr/local/share/ca-certificates/devops-db
sudo cp roots.pem /usr/local/share/ca-certificates/devops-db/devops-db-root.crt
sudo update-ca-certificatesObtain the Root Fingerprint
step certificate fingerprint roots.pemExample fingerprint:
b8d4d77badb5aeca4d23a67269846aa3dbf3271c94c0cf97e82e482aab6e7732Verify CA Connectivity
curl https://ca.devops-db.internal/healthExpected response:
{"status":"ok"}4. Bootstrap the CLI
Connect the Step CLI to the CA.
step ca bootstrap \
--ca-url https://ca.devops-db.internal \
--fingerprint b8d4d77badb5aeca4d23a67269846aa3dbf3271c94c0cf97e82e482aab6e7732This creates the following configuration:
~/.step/config
~/.step/certs/root_ca.crt
Jenkins TLS Configuration
Generate the token:
TOKEN=$(step ca token jenkins.devops-db.internal \
--provisioner vm-hosts \
--key jwk-provisioner-pub.json)Then issue the certificate:
step ca certificate \
jenkins.devops-db.internal \
jenkins.devops-db.internal.crt \
jenkins.devops-db.internal.key \
--token $TOKENConvert the Certificate to PKCS12
Jenkins requires a Java keystore.
openssl pkcs12 -export \
-in jenkins.crt \
-inkey jenkins.key \
-out jenkins.p12 \
-name jenkins \
-CAfile ~/.step/certs/root_ca.crt \
-caname rootPassword used:
1234qwer
Why Jenkins Requires PKCS12 Certificates
Unlike most modern services, which can directly use PEM certificates, Jenkins runs on the Java Virtual Machine (JVM) and therefore uses the Java security infrastructure.
The JVM does not natively use separate .crt and .key files.
Instead, it expects certificates to be stored in a Java KeyStore (JKS) or PKCS12 keystore.
For this reason, the certificate generated by Step-CA must be converted.
Install the Keystore
sudo mv jenkins.p12 /var/lib/jenkins/
sudo chown jenkins:jenkins /var/lib/jenkins/jenkins.p12
sudo chmod 600 /var/lib/jenkins/jenkins.p12Configure Jenkins HTTPS
Edit:
sudo vi /etc/default/jenkinsJENKINS_ARGS="--webroot=/var/cache/$NAME/war --httpPort=-1 --httpsPort=8443 --httpsKeyStore=/var/lib/jenkins/jenkins.p12 --httpsKeyStorePassword=1234qwer"Then update the systemd configuration:
sudo vi /lib/systemd/system/jenkins.service[Service]
Environment="JENKINS_PORT=-1"
Environment="JENKINS_HTTPS_PORT=443"
Environment="JENKINS_HTTPS_KEYSTORE=/var/lib/jenkins/jenkins.p12"
Environment="JENKINS_HTTPS_KEYSTORE_PASSWORD=1234qwer"Vault TLS Configuration
Generate a token:
TOKEN=$(step ca token vault.devops-db.internal \
--provisioner vm-hosts \
--key jwk-provisioner-pub.json)Issue the certificate:
step ca certificate \
vault.devops-db.internal \
vault.devops-db.internal.crt \
vault.devops-db.internal.key \
--token $TOKENInstall the Certificate
mkdir -p /etc/vault/tls/
cp vault.devops-db.internal.crt /etc/vault/tls/
cp vault.devops-db.internal.key /etc/vault/tls/Set permissions:
chown -R vault:vault /etc/vault
chmod 750 /etc/vault
chmod 750 /etc/vault/tls
chmod 600 /etc/vault/tls/vault.devops-db.internal.key
chmod 644 /etc/vault/tls/vault.devops-db.internal.crtAllow Vault to Bind to Port 443
setcap cap_ipc_lock,cap_net_bind_service=+ep /usr/bin/vaultVerify:
getcap /usr/bin/vaultExpected output:
/usr/bin/vault cap_net_bind_service,cap_ipc_lock=epConfigure Vault Listener
Edit:
vi /etc/vault.d/vault.hcllistener "tcp" {
address = "0.0.0.0:443"
tls_cert_file = "/etc/vault/tls/vault.devops-db.internal.crt"
tls_key_file = "/etc/vault/tls/vault.devops-db.internal.key"
tls_disable_client_certs = true
}Restart Vault:
sudo systemctl daemon-reload
sudo systemctl restart vaultGitLab TLS Configuration
Generate a certificate:
TOKEN=$(step ca token gitlab.devops-db.internal \
--provisioner vm-hosts \
--key jwk-provisioner-pub.json)step ca certificate \
gitlab.devops-db.internal \
gitlab.devops-db.internal.crt \
gitlab.devops-db.internal.key \
--token $TOKENInstall the Certificate
mkdir -p /etc/gitlab/ssl
cp gitlab.devops-db.internal.crt /etc/gitlab/ssl/
cp gitlab.devops-db.internal.key /etc/gitlab/ssl/Set permissions:
chmod 600 /etc/gitlab/ssl/gitlab.devops-db.internal.key
chmod 644 /etc/gitlab/ssl/gitlab.devops-db.internal.crtConfigure GitLab TLS
Edit:
vi /etc/gitlab/gitlab.rbexternal_url "https://gitlab.devops-db.internal"
nginx['ssl_certificate'] = "/etc/gitlab/ssl/gitlab.devops-db.internal.crt"
nginx['ssl_certificate_key'] = "/etc/gitlab/ssl/gitlab.devops-db.internal.key"
nginx['redirect_http_to_https'] = trueApply the configuration:
gitlab-ctl reconfigure