Back to blog
Blog

Linux VPS certificate automation with Step CA in 2026: private mTLS for internal services without public ACME

Linux VPS certificate automation with Step CA: issue and rotate private TLS/mTLS certs for internal services in 2026.

By Anurag Singh
Updated on Apr 18, 2026
Category: Blog
Share article
Linux VPS certificate automation with Step CA in 2026: private mTLS for internal services without public ACME

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 port 9001).

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:

  • --acme enables ACME endpoints for internal ACME clients (optional, but useful later).
  • The provisioner name ops-jwk gives 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/.step on 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:

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-timesyncd or chrony everywhere.
  • Wrong SANs. If you connect via api01 but the cert only includes api01.int.example, TLS validation fails. Add all real hostnames in the SAN list.
  • Nginx points to the wrong CA file. ssl_client_certificate must 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.

  1. Disable mTLS enforcement first: set ssl_verify_client off; in the Nginx server block, then nginx -t and reload.
  2. 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.
  3. Stop renewal timer: sudo systemctl disable --now renew-api01-cert.timer.
  4. 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.