← Back to list

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…

Ganesh Velrajan · 2026-01-23 06:43 · 0 claps · 6.7 min read
#self-signed-certificate #acme-server #certificate-authority #certificate-management #certificate-automation
Open on Medium ↗
Wiki topics: BIZ · Business Strategy LIT · Literature & Writing ☁️ · DevOps & Cloud 📟 · Gadgets & IoT

Run Your Own Private CA and ACME Server

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:

  • sudo is required in certbot's standalone mode so that it can run a HTTP server on port 80 to complete the http-01 challenge.
  • If you already have a HTTP webserver running, you can use webroot mode instead.
  • With the appropriate plugin certbot also supports the dns-01 challenge 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 --server flag
  • Make acme.sh to trust your root certificate using the --ca-bundle flag

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