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:

  1. The Nexus server certificate
  2. 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.asc
cat << 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
EOF
apt update
apt install step-cli

3. Installing the Root CA

The Step-CA server publishes the root certificate.

curl -O -k https://ca.devops-db.internal/roots.pem

Inspect the certificate:

openssl x509 -in roots.pem -text -noout

Trust 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-certificates

Obtain the Root Fingerprint

step certificate fingerprint roots.pem

Example fingerprint:

b8d4d77badb5aeca4d23a67269846aa3dbf3271c94c0cf97e82e482aab6e7732

Verify CA Connectivity

curl https://ca.devops-db.internal/health

Expected 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 b8d4d77badb5aeca4d23a67269846aa3dbf3271c94c0cf97e82e482aab6e7732

This 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 $TOKEN

Convert 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 root

Password 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.p12

Configure Jenkins HTTPS

Edit:

sudo vi /etc/default/jenkins
JENKINS_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 $TOKEN

Install 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.crt

Allow Vault to Bind to Port 443

setcap cap_ipc_lock,cap_net_bind_service=+ep /usr/bin/vault

Verify:

getcap /usr/bin/vault

Expected output:

/usr/bin/vault cap_net_bind_service,cap_ipc_lock=ep

Configure Vault Listener

Edit:

vi /etc/vault.d/vault.hcl
listener "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 vault

GitLab 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 $TOKEN

Install 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.crt

Configure GitLab TLS

Edit:

vi /etc/gitlab/gitlab.rb
external_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'] = true

Apply the configuration:

gitlab-ctl reconfigure