Setup and Run Your Own Private CA and ACME Server for mTLS, IoT, DevOps and Internal Services
In the previous article, we discussed what is ACME, how it works and how it helps automate certificate management, so that you could…

Run Your Own Private CA and ACME Server
Setup and Run Your Own Private CA and ACME Server for mTLS, IoT, DevOps and Internal Services
In the previous article, we discussed what is ACME, how it works and how it helps automate certificate management, so that you could automatically issue certificates through DevOps to internal services such as infrastructures, workloads, and apps.
In this tutorial, we’ll discuss how to setup and run your own private CA server using BastionXP with ACME support, so that you could automatically issue certificates to internal infrastructures, workloads, and apps using ACME clients, tools and client libraries already available in the market.
BastionXP CA supports ACME protocol, meaning you can get rid of manually provisioning certificates for internal use and automate your entire certificate management workflow — creation, signing, distribution and renewal.
Why Use BastionXP Private CA ACME Server?
BastionXP Private CA with ACME server support helps organizations take full ownership of their certificate infrastructure without the complexity traditionally associated with PKI. Instead of relying on public certificate authorities (like Let’s Encrypt) or manual certificate issuance, teams can operate a self-hosted, automated, and policy-driven CA that is purpose-built for internal systems, private networks, and modern cloud environments.
With native ACME protocol support, BastionXP enables seamless automation of certificate issuance, renewal, and rotation across servers, Kubernetes clusters, internal applications, APIs, and IoT devices. This eliminates certificate sprawl, reduces operational risk from expired certificates, and removes the need for fragile scripts or manual workflows. Existing ACME clients such as Certbot, acme.sh, and platform-native integrations can be used without modification, allowing teams to adopt BastionXP with minimal friction.
Security teams benefit from stronger trust boundaries and zero-trust alignment. Certificates issued by BastionXP Private CA are scoped exclusively for internal use, preventing unintended external trust while enabling mTLS, workload identity, and service-to-service authentication. Fine-grained policies, short-lived certificates, and centralized lifecycle control significantly reduce the blast radius of compromised credentials and align with modern security best practices.
For organizations operating in regulated, air-gapped, or hybrid environments, BastionXP removes the dependency on external CAs and internet connectivity. This makes it ideal for enterprises with compliance requirements, on-prem infrastructure, private cloud deployments, or edge environments where public CA usage is impractical or prohibited.
Overall, BastionXP Private CA with ACME server support delivers a modern, automated, and secure private PKI that scales with organizational growth — helping platform, DevOps, and security teams move faster while maintaining strong cryptographic trust and operational control.
How to setup and run BastionXP CA with ACME Server for DevOps Automation
Download and Install
Follow the instructions here to download and install BastionXP for your OS version.
Configuration
Enable ACME protocol based certificate provisioner in your BastionXP CA instance using the below sample configuration.
{
"mode": "auth",
"email": "admin@example.com",
"gateway_domain": "ca.internal.example.com",
"provisioners": [
{
"type": "ACME",
"name": "acme",
"tos": "https://www.example.com/tos",
"website": "https://www.example.com",
"dns_server": "dns.internal.example.com:1053",
"challenges": ["http-01", "dns-01"],
"policy": {
"x509": {
"dns": {
"allow": ["*.internal.example.com"],
"deny": ["abc.internal.example.com", "xyz.internal.example.com"]
},
"ip": {
"allow": ["10.1.1.0/24", "20.1.1.4", "30.2.29.125/32"],
"deny": ["2.2.0.0/16", "3.4.5.1"]
}
}
}
}
]
}
ACME Directory:
The ACME directory URL is: https://ca.internal.example.com/acme/directory
How to get a certificate from BastionXP
Using Certbot
To register an ACME account with the BastionXP CA using certbot, use the below command:
$ sudo certbot register --agree-tos -m [email protected] \
--server https://ca.internal.example.com/acme/directory
To get a certificate from BastionXP CA using certbot you need to:
- Provide certbot with your ACME directory URL using the -server flag
- Make certbot to trust your CA’s root certificate using the REQUESTS_CA_BUNDLE environment variable
For example:
certbot certonly -n --standalone -d db-001.internal.example.com \
--server https://ca.internal.example.com/acme/directory
Description:
sudois required in certbot's standalone mode so that it can run a HTTP server on port 80 to complete thehttp-01challenge.- If you already have a HTTP webserver running, you can use webroot mode instead.
- With the appropriate plugin
certbotalso supports thedns-01challenge for most popular DNS providers such as AWS, GCP, Cloudflare and more.
To renew all your certificates you’ve installed using cerbot, run the below command:
Using acme.sh
To get a certificate from BastionXP CA using acme.sh you need to:
- Provide acme.sh with your ACME directory URL using the
--serverflag - Make
acme.shto trust your root certificate using the--ca-bundleflag
For example:
acme.sh can solve the http-01 challenge in standalone mode and webroot mode. It can also solve the dns-01 challenge for various DNS providers.
Using Lego
To get a certificate using Lego you can use the below sample Go code to use the Lego library:
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"fmt"
"log"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/http01"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
)
// You'll need a user or account type that implements acme.User
type MyUser struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *MyUser) GetEmail() string {
return u.Email
}
func (u MyUser) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *MyUser) GetPrivateKey() crypto.PrivateKey {
return u.key
}
func main() {
// Create a user. New accounts need an email and private key to start.
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatal(err)
}
myUser := MyUser{
Email: "admin@example.com",
key: privateKey,
}
config := lego.NewConfig(&myUser)
config.CADirURL = "https://ca.internal.example.com/acme/directory"
config.Certificate.KeyType = certcrypto.EC256
// A client facilitates communication with the CA server.
client, err := lego.NewClient(config)
if err != nil {
log.Fatal(err)
}
err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "80"))
if err != nil {
log.Fatal("HTTP-01 challenge error: ", err)
}
// New users will need to register
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
log.Fatal(err)
}
myUser.Registration = reg
request := certificate.ObtainRequest{
Domains: []string{"db-001.internal.example.com"}, // Obtain cert for this domain
Bundle: true,
}
certificates, err := client.Certificate.Obtain(request)
if err != nil {
log.Fatal(err)
}
// Each certificate comes back with the cert bytes, the bytes of the client's
// private key, and a certificate URL. SAVE THESE TO DISK.
fmt.Printf("%#v\n", certificates)
// ... all done.
}
Using Go
You can use the Go’s simple ACME client (Autocert) library to obtain a certificate for your Go server on-the-fly from BastionXP CA, use the below sample code:
package main
import (
"crypto/tls"
"log"
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
func main() {
dirCachePath := filepath.Join("/tmp", "acme-cert")
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("db-001.internal.example.com"), //Your domain here
Cache: autocert.DirCache(dirCachePath), //Folder for storing certificates
Client: &acme.Client{DirectoryURL: "https://ca.internal.example.com/acme/directory"},
}
tlsConfig := &tls.Config{
GetCertificate: certManager.GetCertificate,
}
log.Println("ACME HTTP server listening on port: 80")
go http.ListenAndServe(":80", certManager.HTTPHandler(nil))
// Auth server listens on port 443
server := http.Server{
Addr: ":443",
Handler: gin.Default(),
TLSConfig: tlsConfig,
}
defer server.Close()
log.Println("Server listening on port: ", "443")
log.Fatal(server.ListenAndServeTLS("", "")) // force to use tls.Config
}
Using Node.JS
You can use the NodeJS acme-client library to obtain a certificate for your NodeJS server on-the-fly from BastionXP CA, by using the below sample code:
const acme = require('acme-client');
const express = require('express');
const http = require('http');
const DIRECTORY_URL = 'https://ca.internal.example.com/acme/directory'
const DOMAIN = 'abc.internal.example.com'
const EMAIL = 'admin@example.com'
/**
* HTTP server for HTTP-01 challenge
*/
const app = express();
// Global store to hold active challenges
const activeChallenges = new Map();
/**
* 1. The Express Route
* This serves the keyAuthorization to the ACME CA
*/
app.get('/.well-known/acme-challenge/:token', (req, res) => {
const token = req.params.token;
const keyAuth = activeChallenges.get(token);
console.log(`[ACME] Challenge requested. Token: ${token} | Found: ${!!keyAuth}`);
if (!keyAuth) {
return res.status(404).send('Challenge not found');
}
// ACME requires text/plain or no content-type at all
res.set('Content-Type', 'text/plain');
res.send(keyAuth);
});
const httpServer = http.createServer(app).listen(80, () => {
console.log('Challenge server listening on port 80');
});
/**
* 2. The Provisioning Functions
*/
async function myCustomChallengeProvisioner(token, keyAuthorization) {
console.log(`[ACME] Storing challenge for token: ${token}`);
activeChallenges.set(token, keyAuthorization);
}
async function myCustomChallengeRemover(token) {
console.log(`[ACME] Removing challenge for token: ${token}`);
activeChallenges.delete(token);
}
async function runAcmeClient() {
try {
/* 1. Initialize Client */
const client = new acme.Client({
directoryUrl: DIRECTORY_URL, // Private CA URL
accountKey: await acme.crypto.createPrivateKey()
});
/* FIX: Register the account before doing anything else */
await client.createAccount({
termsOfServiceAgreed: true,
contact: ['mailto:'+EMAIL]
});
/* 2. Create Order */
const order = await client.createOrder({
identifiers: [{ type: 'dns', value: DOMAIN }]
});
/* 3. Handle Authorizations & Challenges */
const authorizations = await client.getAuthorizations(order);
for (const authz of authorizations) {
const challenge = authz.challenges.find(c => c.type === 'http-01'); // or dns-01
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
// ACTION: Provision your challenge here (e.g., write file to web server)
await myCustomChallengeProvisioner(challenge.token, keyAuthorization);
/* Notify CA that challenge is ready */
await client.completeChallenge(challenge);
/* Wait for CA to validate this specific authorization */
await client.waitForValidStatus(authz);
await myCustomChallengeRemover(challenge.token);
}
/* 4. Finalize Order (Transitions from 'ready' to 'processing'/'valid') */
const [key, csr] = await acme.crypto.createCsr({
altNames: [DOMAIN],
});
// Submitting the CSR to the CA
await client.finalizeOrder(order, csr);
/* 5. Wait for Certificate Issuance */
// Private CAs may take time to sign; poll until status is 'valid'
const finalizedOrder = await client.waitForValidStatus(order);
/* 6. Download Certificate */
const certificate = await client.getCertificate(finalizedOrder);
console.log('Certificate issued:\n', certificate);
return { key, certificate };
} catch (err) {
console.error('Error:', err);
} finally {
console.log('Closing HTTP server...');
// This allows the process to exit once the server stops listening
httpServer.close();
}
}
runAcmeClient().then(() => {
console.log('ACME client finished.');
});
Conclusion
Deploying an internal Certificate Authority is most effective when certificate lifecycle management can be fully automated and seamlessly integrated into existing infrastructure.
BastionXP Private CA achieves this by providing a robust, ACME-compliant interface that works reliably with a wide range of industry-standard ACME clients, including Certbot, lego, Go autocert, and custom implementations.
This compatibility allows teams to leverage familiar tooling while automating certificate issuance, renewal, and rotation for internal services such as microservices, APIs, ingress controllers, load balancers, and device endpoints.
By supporting multiple ACME challenge types and accommodating the real-world behaviors of different clients, BastionXP CA enables organizations to embed certificate management directly into CI/CD pipelines, infrastructure-as-code workflows, and service bootstrap processes.
Certificates can be issued on demand, renewed automatically, and rotated without downtime-reducing operational overhead and eliminating manual key management.
As internal environments grow more dynamic and security requirements become stricter, BastionXP’s standards-aligned and interoperable ACME implementation provides a scalable foundation for enforcing encrypted communication, service identity, and Zero Trust principles across modern infrastructure.
Originally published at https://www.bastionxp.com/blog/private-ca-acme-mtls-iot-devops-zero-trust/
.
메타데이터
- post_id
- 3550ef98e0eb
- slug
- setup-and-run-your-own-private-ca-and-acme-server-for-mtls-iot-and-internal-services-3550ef98e0eb
- url
- https://medium.com/@ganeshvelrajan/setup-and-run-your-own-private-ca-and-acme-server-for-mtls-iot-and-internal-services-3550ef98e0eb
- canonical_url
- https://medium.com/@ganeshvelrajan/setup-and-run-your-own-private-ca-and-acme-server-for-mtls-iot-and-internal-services-3550ef98e0eb
- author_url
- https://medium.com/@ganeshvelrajan
- status
- ok
- fetched_at
- 2026-07-11 13:37:47