Building an Internal CA for your Homelab with HashiCorp Vault
Table of Contents
Welcome to another episode of my homelab documentation series. If you’re running multiple services in your homelab, like I do, you’ve probably wrestled with self‑signed certificates, browser warnings, and the hassle of renewing TLS certs. HashiCorp Vault’s PKI secrets engine can solve all of that, acting as your own internal Certificate Authority (CA) with automated certificate issuance and renewal, it is a powerful tool for managing secrets, encryption keys, and identity-based access to sensitive data. Vault came some time ago as recommendation from a good friend of mine, he was already running it as CA on his homelab so I decided to give it a try.
In this guide, I’ll walk through deploying Vault in your homelab using Portainer with TLS enabled, so you can access it securely over HTTPS. This guide is intentionally detailed so you can follow along exactly the same procedure I used to deploy Vault in my homelab.
Why Run Vault in Your Homelab? #
- Learn production practices: experiment with TLS, unsealing, and persistent storage.
- Integrate with other services: test Vault as a backend for Kubernetes, Docker, or personal projects.
- Build confidence: understand operational workflows before deploying to production.
Prerequisites #
- A server or VM running Docker + Portainer.
- Basic understanding of Docker volumes.
- TLS certificates.
For out initial setup, we’ll generate self-signed certificates and place everything under /opt/vault in our Docker host, we will replace this self-signed certificates with final Vault certificates later. First create the directories in the Docker host.
sudo mkdir -p /opt/vault/{config,certs,data}
sudo chown $(id -u):$(id -g) /opt/vault -R
cd /opt/vault/certs
This creates:
/opt/vault/config→ configuration files./opt/vault/certs→ TLS certs./opt/vault/data→ persistent storage.
Create a small openssl config.
cat > /opt/vault/certs/vault-openssl.cnf <<'EOF'
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = vault.starlabs.local
[v3_req]
keyUsage = keyEncipherment, dataEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = vault.starlabs.local
DNS.2 = localhost
IP.1 = 127.0.0.1
IP.2 = 192.168.1.20
EOF
Add your homelab IP as IP.2 value under [alt_names] before creating the cert, e.g. I added IP.2 = 192.168.1.50 since that’s the IP address of my Portainer instance.
Generate the key, CSR and self-signed certs:
cd /opt/vault/certs
openssl req -new -nodes -newkey rsa:2048 \
-keyout vault.key -out vault.csr -config vault-openssl.cnf
openssl x509 -req -in vault.csr -signkey vault.key \
-out vault.crt -days 3650 -extensions v3_req -extfile vault-openssl.cnf
rm vault.csr
chmod 600 vault.key
Vault configuration #
Create /opt/vault/config/config.hcl:
ui = true
api_addr = "https://vault.starlabs.local:8200"
cluster_addr = "https://vault.starlabs.local:8201"
listener "tcp" {
address = "0.0.0.0:8200"
tls_cert_file = "/vault/certs/vault.crt"
tls_key_file = "/vault/certs/vault.key"
}
storage "file" {
path = "/vault/data"
}
ui=true- Enables Vault web UI.listener "tcp"- Listens on all interfaces, port 8200, with TLS enabled.storage "file"- tores Vault data in the mounted volume (/opt/vault/data).
Portainer Deployment #
Open Portainer.
Navigate to Stacks -> Add Stack.
Name the stack
vault.Paste the below YAML. You can adjust host paths if you used different directories.
version: '3.9' services: vault: image: hashicorp/vault:latest container_name: vault restart: unless-stopped cap_add: - IPC_LOCK ports: - "8200:8200" environment: VAULT_ADDR: https://vault.starlabs.local:8200 volumes: - /opt/vault/config:/vault/config:ro - /opt/vault/certs:/vault/certs:ro - /opt/vault/data:/vault/data - /dev/null:/etc/vault.d # mask defaults entrypoint: ["vault"] # ensure only vault runs command: ["server","-config=/vault/config/config.hcl"]Deploy the stack.
In Portainer watch logs and ensure the container enters running state. Vault should now be running as a container with TLS enabled.
Portainer Troubleshooting Checklist for Vault #
When deploying Vault in Portainer, you may encounter issues that don’t happen when running docker run manually, actually I run into some of them during my Vault setup. Here are common pitfalls and fixes:
Double Vault Processes → “address already in use” #
Symptom: Logs show
Error initializing listener of type tcp: listen tcp4 0.0.0.0:8200: bind: address already in useCause: Portainer/Compose runs the default Vault
docker-entrypoint.sh, which launches Vault with configs from/etc/vault.d. Your stack command then starts a second Vault process → port conflict.Fix: Override the entrypoint and mask default configs:
entrypoint: ["vault"] command: ["server", "-config=/vault/config/config.hcl"] volumes: - /dev/null:/etc/vault.d # masks default configs
TLS Key Permission Denied #
Symptom:
error loading TLS cert: open /vault/certs/vault.key: permission deniedCause: Container’s
vaultuser can’t read the private key.Fix:
sudo chown root:vault /opt/vault/certs/vault.key sudo chmod 640 /opt/vault/certs/vault.key
mlock / IPC_LOCK Errors #
Symptom:
Failed to lock memory: cannot allocate memoryCause:
mlockisn’t available inside most Docker environments.Fix: Add capability and/or disable mlock in config:
cap_add: - IPC_LOCKAnd in
config.hcl:disable_mlock = true
Check Config Before Deploy #
Run Vault in debug/verify mode outside Portainer before stack deployment:
docker run --rm -it \ --cap-add=IPC_LOCK \ -v /opt/vault/config:/vault/config:ro \ -v /opt/vault/certs:/vault/certs:ro \ -v /opt/vault/data:/vault/data \ hashicorp/vault server -config=/vault/config/config.hcl
Initialize and Unseal Vault #
Once the container is running we need to initialize Vault.
Check status by running this inside the container:
docker exec -it vault vault status
You may see a TLS error like this:
Error checking seal status: Get "https://vault.starlabs.local:8200/v1/sys/seal-status":
tls: failed to verify certificate: x509: certificate signed by unknown authority
This is expected when using a self-signed or custom CA. You can bypass it for bootstrap/testing with:
docker exec -it vault vault status -tls-skip-verify
Example output:
Key Value
--- -----
Seal Type shamir
Initialized false
Sealed true
Total Shares 0
Threshold 0
Unseal Progress 0/0
Unseal Nonce n/a
Version 1.20.3
Build Date 2025-08-27T10:53:27Z
Storage Type file
HA Enabled false
If Vault is uninitialized, initialize and save the keys (example uses 1 share/threshold for simplicity — for real setups use multiple shares):
docker exec -i vault vault operator init -key-shares=1 -key-threshold=1 -format=json -tls-skip-verify > /opt/vault/init.json
Store the init.json somewhere safe (it contains unseal key(s) and root token). Extract values:
jq -r '.unseal_keys_b64[]' /opt/vault/init.json > /opt/vault/unseal.key
jq -r '.root_token' /opt/vault/init.json > /opt/vault/root.token
chmod 600 /opt/vault/unseal.key /opt/vault/root.token
Unseal:
for key in `cat /opt/vault/unseal.key`; do
docker exec -i vault vault operator unseal -tls-skip-verify $key
done
Login with the root token (inside the container):
ROOT=$(cat /opt/vault/root.token)
docker exec -i vault vault login -tls-skip-verify "$ROOT"
I’ve created a small script in bash to unseal Vault after each restart, replace the hostname and paths with your own values.
#!/user/bin/env bash
# Path to your init.json file
UNSEAL_FILE="/opt/vault/unseal.key"
UNSEAL_KEYS=$(cat $UNSEAL_FILE)
# Vault address (should match your config)
export VAULT_ADDR="https://vault.starlabs.local:8200"
# If using self-signed certs, you may need this
export VAULT_SKIP_VERIFY=true
echo "Starting Vault unseal process..."
# Loop through each key and unseal
for key in $UNSEAL_KEYS; do
echo "Applying unseal key..."
docker exec -i vault vault operator unseal -tls-skip-verify $key
done
echo "Vault should now be unsealed."
Verify TLS from the host #
If you used a self-signed cert, point curl to the cert:
curl --cacert /opt/vault/certs/vault.crt https://vault.starlabs.local:8200/v1/sys/health
You can also set:
export VAULT_ADDR='https://vault.starlabs.local:8200'
export VAULT_CACERT='/opt/vault/certs/vault.crt'
vault status # run locally if Vault CLI is installed
Access Vault UI #
Open https://vault.starlabs.local:8200
Your browser may warn about the self-signed cert. Import vault.crt into your OS/browser trust store to avoid warnings, but is not mandatory since we will generate a certificate for Vault later.
Enable PKI and Configure CA #
Since we are still using a self-signed cert, we still need to use the -tls-skip-verify flag or VAULT_SKIP_VERIFY=true env var inside the container.
Bootstrap PKI #
First execute a shell inside the container:
docker exec -it vault sh
Once you are inside the container, enable the PKI secrets engine and configure it:
export VAULT_SKIP_VERIFY=true
vault secrets enable pki
Configure Vault as Root CA #
Create the root CA. Still with VAULT_SKIP_VERIFY=true:
vault secrets tune -max-lease-ttl=87600h pki
vault write pki/root/generate/internal \
common_name="starlabs.local CA" \
ttl=87600h
vault write pki/config/urls \
issuing_certificates="https://vault.starlabs.local:8200/v1/pki/ca" \
crl_distribution_points="https://vault.starlabs.local:8200/v1/pki/crl"
The first command will return the certificate and the issuing CA. Save the certificate as root-ca.pem, this is your root CA. You can install this certificate in your OS/browser trust store to avoid warnings. In general Every machine / service that must trust your certificates needs this CA.
Examples:
Linux #
sudo cp starlabs-root-ca.pem /usr/local/share/ca-certificates/starlabs-root-ca.crt
sudo update-ca-certificates
macOS #
- Import into Keychain → System → Certificates → Always Trust
Windows #
- Import into “Trusted Root Certification Authorities”
Docker containers #
- Add to
/usr/local/share/ca-certificates - Run
update-ca-certificates
Kubernetes nodes #
- Add to host trust store
- Or inject into trust bundle if needed
Issue a TLS certificate for Vault #
Create a role for your domain:
vault write pki/roles/lab-dot-local \
allowed_domains="starlabs.local" \
allow_subdomains=true \
max_ttl="720h"
Issue a certificate for vault.starlabs.local:
These commands must be executed outside the Vault container, in the Docker host or your admin machine. Instal vault CLI in your host and execute the vault commands from it. You can use the jq command to extract the values:
vault write -format=json pki/issue/lab-dot-local \
common_name="vault.starlabs.local" \
ip_sans="127.0.0.1" \
> vault-starlabs-local.json
Save, using jq, the following values:
certificateprivate_keyissuing_ca
jq -r .data.certificate vault-starlabs-local.json > vault-starlabs-local.crt
jq -r .data.private_key vault-starlabs-local.json > vault-starlabs-local.key
jq -r .data.issuing_ca vault-starlabs-local.json > starlabs-ca.pem
Replace Vault self-signed cert #
Copy the cert and key inside the container:
cp vault-starlabs-local.crt /opt/vault/certs/vault.crt
cp vault-starlabs-local.key /opt/vault/certs/vault.key
Review and update the Vault container config if you need to:
listener "tcp" {
address = "0.0.0.0:8200"
tls_cert_file = "/vault/certs/vault.crt"
tls_key_file = "/vault/certs/vault.key"
}
Restart the container:
docker restart vault
Remember to unseal Vault after the restart either manually or with the script we created before.
Trust Vault CA everywhere #
In the host:
sudo cp starlabs-ca.pem /usr/local/share/ca-certificates/vault-ca.crt
sudo update-ca-certificates
From your host or your admin machine test vault without VAULT_SKIP_VERIFY:
unset VAULT_SKIP_VERIFY
vault status
With this Vault is configured to issue certificates for your homelab services.
In the next article we will see how to integrate Vault with Traefik.
–Juanma