
Public ACME certificates (Let’s Encrypt) solve the “browser trust” problem. They don’t solve the “internal trust” problem. If you run private APIs, admin dashboards, CI runners, or service-to-service traffic on a VPS, you still need short-lived certificates, automated renewal, and a clean way to revoke access when a node is compromised.
This post walks through Linux VPS certificate automation using Step CA as your private certificate authority. The aim is straightforward: issue and rotate TLS and mTLS certs for internal services without exposing ACME challenges to the public internet, and without babysitting PEM files every 90 days.
Why Step CA is a good fit for VPS teams
Step CA gives you a private CA you can actually operate day to day: short-lived certificates, enforceable issuance policy, multiple provisioner types (JWK/OIDC/SSH), and a CLI that plays nicely with systemd and standard Linux tooling.
- Short lifetimes by default (hours or days), which limits damage if a key leaks.
- mTLS becomes routine for internal APIs instead of a recurring “security sprint.”
- No need to open public HTTP ports just to satisfy ACME challenges for private names like
api.int.
If you already centralize logs or run incident drills, Step CA’s issuance events are also easy to ship and search later.
Prerequisites (what you need before you start)
- A VPS running Debian 12 or Ubuntu 24.04+ (examples below use Debian 12 paths and defaults).
- Root access (or sudo).
- A private DNS zone or at least consistent hostnames (even if via
/etc/hosts) for internal names. - Basic comfort with systemd, OpenSSL output, and Nginx.
Infrastructure note: this goes smoother on a clean VPS where you control firewall rules and backups. A HostMyCode VPS is a solid baseline for internal PKI work because you can keep the CA private, keep rules tight, and snapshot before risky changes.
Architecture: one CA VPS, many clients
You’ll run two roles:
- CA node:
ca01.int.example(Step CA + step-cli). Only your private network/VPN can reach it on TCP 443. - Service node:
api01.int.example(Nginx in front of an internal API on port9001).
And you’ll issue two certificate types:
- Server TLS cert for Nginx (client-to-service encryption).
- mTLS client cert for a single internal client identity (service-to-service authentication).
If you already use a private access fabric, this pairs well with a VPN-first admin model. HostMyCode has a relevant guide on private access patterns: Tailscale VPS VPN setup.
Step-by-step: set up Step CA on your VPS (Debian 12)
Consider this a checklist. If DNS and firewall rules are already in good shape, you can finish in under an hour.
1) Create a dedicated user and directories
sudo useradd --system --home /var/lib/step --create-home --shell /usr/sbin/nologin step
sudo install -d -o step -g step -m 0700 /var/lib/step/.step
Expected output: no output on success. Verify the home exists:
getent passwd step
ls -ld /var/lib/step /var/lib/step/.step
2) Install step-cli and step-ca
Smallstep provides packages; on Debian you’ll usually install from their apt repo. Follow the official packaging instructions for 2026, then confirm what you got:
step version
step-ca version
Expected output includes versions like:
Smallstep CLI/0.27.x
Smallstep CA/0.27.x
If your environment can’t use packages, release binaries work. Just be honest with yourself about upgrades and patch tracking. For production, package-managed installs are usually simpler to audit.
3) Initialize your CA with sane defaults
Run init as the step user so the config and keys land in the right place:
sudo -u step -H bash -lc 'step ca init \
--name "HostMyCode Internal CA" \
--dns ca01.int.example \
--address :443 \
--provisioner "ops-jwk" \
--password-file /var/lib/step/ca-pass.txt \
--deployment-type standalone \
--acme'
Notes:
--acmeenables ACME endpoints for internal ACME clients (optional, but useful later).- The provisioner name
ops-jwkgives you an admin token-based enrollment path for machines.
Security pitfall: don’t leave /var/lib/step/ca-pass.txt readable. Lock it down immediately:
sudo chown step:step /var/lib/step/ca-pass.txt
sudo chmod 0600 /var/lib/step/ca-pass.txt
Verify the CA config exists:
sudo -u step -H ls -l /var/lib/step/.step/config/ca.json
4) Create a systemd service for Step CA
Create /etc/systemd/system/step-ca.service:
[Unit]
Description=Smallstep Step CA
After=network-online.target
Wants=network-online.target
[Service]
User=step
Group=step
WorkingDirectory=/var/lib/step
ExecStart=/usr/bin/step-ca /var/lib/step/.step/config/ca.json --password-file /var/lib/step/ca-pass.txt
Restart=on-failure
RestartSec=3
LimitNOFILE=65536
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/step
[Install]
WantedBy=multi-user.target
Then enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now step-ca
sudo systemctl status step-ca --no-pager
Expected output: active (running). Check it’s listening on 443:
sudo ss -lntp | grep ':443'
5) Lock down network access (minimal exposure)
Don’t leave Step CA exposed to the public internet unless you have a very specific reason. If you already run nftables, add a tight allowlist. If you don’t, avoid the classic trap: “temporary” permissive rules that never get revisited.
HostMyCode has a practical nftables logging pattern you can adapt: VPS firewall logging with nftables.
Example policy idea (pseudo, adapt to your environment): allow TCP 443 to the CA only from your VPN subnet 100.64.0.0/10 or a management VPC range.
Enroll a client and automate certificate renewal on Linux
Next you’ll issue a server certificate for Nginx on api01, then wire up a renewal timer so the cert stays fresh with minimal effort.
6) Install step-cli on the service node
On api01.int.example:
step version
If step isn’t installed, install it using the same packaging method as the CA.
7) Trust the CA root certificate
Copy the CA root certificate to the service node. On the CA, it’s typically under:
/var/lib/step/.step/certs/root_ca.crt
Transfer it securely (scp over VPN is fine). Then install it into the OS trust store:
sudo cp root_ca.crt /usr/local/share/ca-certificates/hostmycode-int-root.crt
sudo update-ca-certificates
Expected output includes: 1 added, 0 removed (exact counts vary).
Verification:
grep -R "HostMyCode Internal CA" -n /etc/ssl/certs | head
8) Bootstrap the client with a JWK provisioner token
On the CA node, generate a short-lived token for the host identity api01.int.example:
sudo -u step -H step ca token api01.int.example --provisioner ops-jwk --not-after 15m
Copy the token output over a secure channel. On the service node, request a server certificate:
sudo install -d -m 0755 /etc/nginx/pki
sudo step ca certificate \
api01.int.example \
/etc/nginx/pki/api01.crt \
/etc/nginx/pki/api01.key \
--token "PASTE_TOKEN_HERE" \
--san api01 \
--san api01.int.example \
--not-after 168h
That issues a 7-day certificate. Short lifetimes are the point; automation makes them boring.
Expected output ends with something like Certificate written. Verify:
sudo openssl x509 -in /etc/nginx/pki/api01.crt -noout -subject -issuer -dates
9) Configure Nginx to use the new certificate (and require mTLS)
Assume your internal API listens on 127.0.0.1:9001. Create /etc/nginx/conf.d/internal-api.conf:
upstream internal_api {
server 127.0.0.1:9001;
keepalive 32;
}
server {
listen 8443 ssl;
server_name api01.int.example;
ssl_certificate /etc/nginx/pki/api01.crt;
ssl_certificate_key /etc/nginx/pki/api01.key;
# Trust your internal CA for client cert validation
ssl_client_certificate /etc/ssl/certs/hostmycode-int-root.pem;
ssl_verify_client on;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /healthz {
access_log off;
return 200 "ok\n";
}
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Request-Id $request_id;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_pass http://internal_api;
}
}
One detail: Nginx expects a PEM certificate file at a stable path. On Debian/Ubuntu, update-ca-certificates typically generates PEM symlinks under /etc/ssl/certs. Find the exact filename:
ls -1 /etc/ssl/certs | grep -i hostmycode | head
If you don’t see a .pem file, convert:
sudo openssl x509 -in /usr/local/share/ca-certificates/hostmycode-int-root.crt -out /etc/ssl/certs/hostmycode-int-root.pem
Then test and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
10) Issue a client certificate for your internal caller
On a client machine (another VPS, a CI runner, or an ops workstation on VPN), install step-cli and trust the CA root as you did earlier. Then request a client cert. Start by generating a token for a client identity like svc-internal-reporting:
sudo -u step -H step ca token svc-internal-reporting --provisioner ops-jwk --not-after 15m
On the client:
mkdir -p ~/pki
step ca certificate \
svc-internal-reporting \
~/pki/reporting.crt \
~/pki/reporting.key \
--token "PASTE_TOKEN_HERE" \
--not-after 168h
Verification:
openssl x509 -in ~/pki/reporting.crt -noout -subject -eku
You want to see Extended Key Usage that includes client authentication (Step typically handles that correctly based on templates/policy).
Verification: prove mTLS is actually enforced
Run these checks from the client side. They catch the most common misconfigurations quickly.
11) Confirm the server refuses connections without a client cert
curl -k https://api01.int.example:8443/healthz
Expected output: a TLS handshake failure such as SSL certificate problem or handshake failure, depending on curl/OpenSSL.
12) Confirm the server accepts valid client certs
curl --cacert /usr/local/share/ca-certificates/hostmycode-int-root.crt \
--cert ~/pki/reporting.crt \
--key ~/pki/reporting.key \
https://api01.int.example:8443/healthz
Expected output:
ok
If you proxied headers, confirm your upstream app logs include X-Client-DN and X-Client-Verify.
Automate renewals with systemd timers (no cron guesswork)
Short-lived certs only pay off if renewal is dull and predictable. A good pattern is a oneshot service that renews, plus a timer that runs twice a day. Reload Nginx only after a successful renew.
13) Create a renewal script
Create /usr/local/sbin/renew-api01-cert.sh on api01:
#!/bin/sh
set -eu
CERT=/etc/nginx/pki/api01.crt
KEY=/etc/nginx/pki/api01.key
# Renew in-place if expiring soon; step will re-issue using cached identity/provisioning
step ca renew "$CERT" "$KEY" --force
nginx -t
systemctl reload nginx
Lock it down:
sudo chmod 0750 /usr/local/sbin/renew-api01-cert.sh
sudo chown root:root /usr/local/sbin/renew-api01-cert.sh
Practical note: step ca renew still needs an authentication path. Depending on your provisioner choice, that might be on-host key material, an ACME provisioner, or a bootstrap flow that stores credentials. If you want to avoid long-lived secrets on hosts, Step CA’s ACME provisioner for internal names is often the cleanest approach once trust is established. Keep issuance policy tight either way.
14) Create systemd unit + timer
/etc/systemd/system/renew-api01-cert.service:
[Unit]
Description=Renew mTLS server certificate for api01
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/renew-api01-cert.sh
/etc/systemd/system/renew-api01-cert.timer:
[Unit]
Description=Twice-daily renewal check for api01 certificate
[Timer]
OnCalendar=*-*-* 03,15:17:00
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
Enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now renew-api01-cert.timer
sudo systemctl list-timers --all | grep renew-api01-cert
Expected output: next/last run times shown.
Operational hardening: backup, logs, and incident response
A private CA is sensitive. Treat it like critical infrastructure: isolate it, track access, and back up the CA state carefully.
- Backups: back up
/var/lib/step/.stepon the CA node. If you lose it, you lose issuance history and keys. Encrypt backups and test restore. - Logging: ship Step CA logs to your central log store with tags so you can search by provisioner and subject CN.
- Runbooks: document how to revoke and rotate if a node key leaks.
Two HostMyCode posts you can reuse directly for the “boring but essential” parts:
- restic + S3 backup strategy (works well for backing up CA state securely)
- centralized compliance logging with rsyslog + TLS
Common pitfalls (the stuff that wastes your afternoon)
- Time drift breaks everything. If your CA or client nodes drift by minutes, cert validation fails. Enable
systemd-timesyncdor chrony everywhere. - Wrong SANs. If you connect via
api01but the cert only includesapi01.int.example, TLS validation fails. Add all real hostnames in the SAN list. - Nginx points to the wrong CA file.
ssl_client_certificatemust reference a PEM certificate file, not a directory. - Overexposed CA port. If Step CA is publicly reachable, you’ve increased your attack surface. Put it behind a VPN or strict firewall rules.
- Renewal auth isn’t planned. Issuing a cert with a one-time token is fine. Renewing it requires a sustainable path (ACME provisioner, stored identity, or a controlled re-enrollment process).
Rollback plan (safe exits if you need to revert)
If you switch this on and something starts failing, you want a clean path back to “just TLS” (or even “no TLS”) while you debug.
- Disable mTLS enforcement first: set
ssl_verify_client off;in the Nginx server block, thennginx -tand reload. - Revert certificates: keep the prior Nginx cert/key under a date-stamped name before swapping, for example
/etc/nginx/pki/api01.crt.2026-04-18. Point Nginx back and reload. - Stop renewal timer:
sudo systemctl disable --now renew-api01-cert.timer. - Don’t delete the CA state unless you’re intentionally re-keying. If you wipe
/var/lib/step/.step, you’re creating a new trust root and every client must be re-enrolled.
Next steps (where to take this after the first service works)
- Add policy: restrict which subjects and SANs each provisioner can request, so one token can’t mint certs for everything.
- Automate enrollment for fleets: integrate Step with your configuration management or cloud-init so new nodes self-enroll on boot.
- Move to full service identity: issue unique client certs per service (not per team) and enforce authorization at the app layer using the client DN.
- Observe and alert: alert on unexpected issuance spikes, failed renewals, and revoked serials.
If you’re building an internal API surface and want predictable networking, snapshots, and root access for PKI work, start on a HostMyCode VPS. If you’d rather have patching, baseline hardening, and operational guardrails handled for you, managed VPS hosting is the simpler route for long-lived infrastructure like a private CA.
FAQ: Linux VPS certificate automation with Step CA
Do I need a public domain for Step CA?
No. For internal TLS/mTLS, you can issue certificates for private hostnames and internal DNS zones. You still need consistent naming (DNS or hosts files) so clients validate SANs correctly.
Should I run the CA on the same VPS as my app?
Avoid it. If the app node is compromised, an attacker can potentially mint or steal CA material. Keep the CA on a separate VPS with strict inbound rules.
How short should certificate lifetimes be in 2026?
For internal service identities, 24 hours to 7 days is common if renewal is automated. Start with 7 days, then shorten once you trust your renewal path and alerting.
What’s the simplest way to start if I don’t want to manage tokens for renewals?
Consider using Step CA’s ACME provisioner for internal names and run an ACME client (or step’s ACME tooling) on each node. That gives you a familiar renew loop with fewer bespoke scripts.
How do I know if mTLS is actually protecting my service?
Test both paths: a request without a client cert should fail the handshake, and a request with a valid client cert signed by your CA should succeed. Log $ssl_client_verify and the client DN while you validate.
Summary
Linux VPS certificate automation gets simpler once internal TLS stops being a manual chore. Step CA gives you a private trust root, short-lived certificates, and a practical way to run mTLS across internal services. Keep the CA isolated, renew with systemd timers, and back up CA state like it matters—because it does.
If you want a stable home for your CA and internal services, deploy them on a HostMyCode VPS, and layer on managed VPS hosting if you prefer fewer operational chores.