Article
In today's security-conscious web, TLS/SSL certificates are essential for encrypting communication and establishing trust. Let's Encrypt has revolutionized this space by providing free, automated certificates through the ACME (Automated Certificate Management Environment) protocol. For Java applications—whether web servers, microservices, or internal tools—integrating ACME enables fully automated certificate issuance and renewal, eliminating manual certificate management overhead.
What is Let's Encrypt and ACME?
Let's Encrypt is a free, automated, and open certificate authority that provides TLS certificates. The ACME protocol is the standard that allows automated certificate issuance and management.
Key ACME Concepts:
- Domain Validation: Proving you control a domain through challenges (HTTP-01, DNS-01, TLS-ALPN-01)
- Certificate Issuance: Getting a signed certificate after successful validation
- Certificate Renewal: Automatically renewing certificates before they expire
- Account Management: Creating and managing ACME accounts
Why Java Applications Need ACME Integration
- Zero-Cost TLS: Eliminate certificate costs for development and production
- Automated Renewal: Never worry about certificate expiration again
- DevOps Friendly: Perfect for microservices and containerized environments
- Security: Always up-to-date certificates with strong cryptographic standards
ACME Java Libraries
Several Java libraries implement the ACME protocol:
- acme4j: Popular Java library for ACME integration
- Let's Encrypt clients: Specialized clients with Java support
- Spring Boot starters: For seamless Spring integration
Approach 1: Using acme4j for ACME Integration
1. Add Dependencies:
<!-- pom.xml --> <dependencies> <dependency> <groupId>org.shredzone.acme4j</groupId> <artifactId>acme4j-client</artifactId> <version>3.0.0</version> </dependency> <dependency> <groupId>org.shredzone.acme4j</groupId> <artifactId>acme4j-utils</artifactId> <version>3.0.0</version> </dependency> <!-- For HTTP challenge --> <dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-server</artifactId> <version>11.0.15</version> </dependency> </dependencies>
2. Basic ACME Client Implementation:
@Service
public class AcmeCertificateService {
private static final Logger logger = LoggerFactory.getLogger(AcmeCertificateService.class);
// Let's Encrypt production directory URL
private static final String ACME_SERVER = "https://acme-v02.api.letsencrypt.org/directory";
// Let's Encrypt staging directory URL (for testing)
private static final String ACME_SERVER_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory";
private final Session session;
private Account account;
public AcmeCertificateService(@Value("${acme.use-staging:true}") boolean useStaging) {
String serverUrl = useStaging ? ACME_SERVER_STAGING : ACME_SERVER;
this.session = new Session(serverUrl);
}
public void createAccount(String email) throws AcmeException {
// Create a new account with an email address
AccountBuilder accountBuilder = new AccountBuilder();
accountBuilder.addContact("mailto:" + email);
accountBuilder.agreeToTermsOfService();
accountBuilder.useKeyPair(KeyPairUtils.createKeyPair());
this.account = accountBuilder.create(session);
logger.info("Created new ACME account: {}", email);
}
public X509Certificate requestCertificate(String domain, File keyFile, File certFile)
throws AcmeException, IOException {
// Order the certificate
Order order = account.newOrder().domains(domain).create();
// Perform HTTP-01 challenge
performHttpChallenge(order, domain);
// Execute the order
order.execute();
// Wait for order completion
try {
for (int i = 0; i < 20; i++) {
if (order.getStatus() == Status.VALID) {
break;
}
Thread.sleep(3000L);
order.update();
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new AcmeException("Interrupted while waiting for order", ex);
}
if (order.getStatus() != Status.VALID) {
throw new AcmeException("Order failed: " + order.getStatus());
}
// Download the certificate
Certificate certificate = order.getCertificate();
X509Certificate x509Certificate = certificate.getCertificate();
// Save certificate and key
saveCertificateAndKey(x509Certificate, keyFile, certFile);
logger.info("Successfully issued certificate for domain: {}", domain);
return x509Certificate;
}
private void performHttpChallenge(Order order, String domain) throws AcmeException {
// Find HTTP-01 challenge
Challenge challenge = order.getAuthorizations().stream()
.flatMap(auth -> auth.findChallenge(HttpChallenge.TYPE).stream())
.findFirst()
.orElseThrow(() -> new AcmeException("No HTTP challenge found"));
HttpChallenge httpChallenge = (HttpChallenge) challenge;
// Create the challenge file content
String token = httpChallenge.getToken();
String authorization = httpChallenge.getAuthorization();
// In a real implementation, you would serve this from your web server
serveChallengeFile(token, authorization);
// Trigger the challenge
httpChallenge.trigger();
logger.info("Triggered HTTP challenge for domain: {}", domain);
}
private void serveChallengeFile(String token, String authorization) {
// This method should integrate with your web server to serve the challenge file
// For example, create a temporary endpoint that serves the authorization string
// when accessed at: http://<domain>/.well-known/acme-challenge/<token>
logger.info("Challenge file content - Token: {}, Authorization: {}",
token, authorization);
// Implementation depends on your web framework (Spring MVC, JAX-RS, etc.)
// See the Spring Boot integration example below
}
private void saveCertificateAndKey(X509Certificate certificate, File keyFile, File certFile)
throws IOException {
try (FileWriter certWriter = new FileWriter(certFile)) {
certWriter.write("-----BEGIN CERTIFICATE-----\n");
certWriter.write(Base64.getEncoder().encodeToString(certificate.getEncoded()));
certWriter.write("\n-----END CERTIFICATE-----\n");
}
// Note: The key file should be saved from the KeyPair used during certificate request
logger.info("Saved certificate to: {}", certFile.getAbsolutePath());
}
}
Approach 2: Spring Boot Integration with ACME
1. Spring Boot ACME Auto-Configuration:
@Configuration
@EnableConfigurationProperties(AcmeProperties.class)
public class AcmeAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public AcmeCertificateManager acmeCertificateManager(AcmeProperties properties) {
return new AcmeCertificateManager(properties);
}
@Bean
public AcmeChallengeController acmeChallengeController() {
return new AcmeChallengeController();
}
}
2. Configuration Properties:
@ConfigurationProperties(prefix = "acme")
public class AcmeProperties {
private boolean enabled = true;
private boolean useStaging = true;
private String email;
private List<String> domains = new ArrayList<>();
private String keyStorePath = "acme-keystore.p12";
private String keyStorePassword;
private int renewalDays = 30;
// Getters and setters
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public boolean isUseStaging() { return useStaging; }
public void setUseStaging(boolean useStaging) { this.useStaging = useStaging; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public List<String> getDomains() { return domains; }
public void setDomains(List<String> domains) { this.domains = domains; }
public String getKeyStorePath() { return keyStorePath; }
public void setKeyStorePath(String keyStorePath) { this.keyStorePath = keyStorePath; }
public String getKeyStorePassword() { return keyStorePassword; }
public void setKeyStorePassword(String keyStorePassword) { this.keyStorePassword = keyStorePassword; }
public int getRenewalDays() { return renewalDays; }
public void setRenewalDays(int renewalDays) { this.renewalDays = renewalDays; }
}
3. ACME Challenge Controller for HTTP-01:
@RestController
@RequestMapping("/.well-known/acme-challenge")
public class AcmeChallengeController {
private final Map<String, String> challengeTokens = new ConcurrentHashMap<>();
@PostMapping("/register/{token}")
public ResponseEntity<String> registerChallenge(
@PathVariable String token,
@RequestBody String authorization) {
challengeTokens.put(token, authorization);
return ResponseEntity.ok("Challenge registered");
}
@GetMapping("/{token}")
public ResponseEntity<String> serveChallenge(@PathVariable String token) {
String authorization = challengeTokens.get(token);
if (authorization != null) {
return ResponseEntity.ok(authorization);
}
return ResponseEntity.notFound().build();
}
@DeleteMapping("/{token}")
public ResponseEntity<Void> removeChallenge(@PathVariable String token) {
challengeTokens.remove(token);
return ResponseEntity.noContent().build();
}
}
4. Certificate Manager Service:
@Service
public class AcmeCertificateManager {
private static final Logger logger = LoggerFactory.getLogger(AcmeCertificateManager.class);
private final AcmeProperties properties;
private final AcmeChallengeController challengeController;
private final Session session;
private Account account;
public AcmeCertificateManager(AcmeProperties properties,
AcmeChallengeController challengeController) {
this.properties = properties;
this.challengeController = challengeController;
String serverUrl = properties.isUseStaging() ?
"https://acme-staging-v02.api.letsencrypt.org/directory" :
"https://acme-v02.api.letsencrypt.org/directory";
this.session = new Session(serverUrl);
initializeAccount();
}
private void initializeAccount() {
try {
// Try to load existing account key pair
File accountKeyFile = new File("account-key.pem");
KeyPair accountKeyPair = loadKeyPair(accountKeyFile);
this.account = new AccountBuilder()
.useKeyPair(accountKeyPair)
.create(session);
} catch (Exception e) {
// Create new account
try {
createNewAccount();
} catch (AcmeException ex) {
throw new RuntimeException("Failed to initialize ACME account", ex);
}
}
}
private void createNewAccount() throws AcmeException {
KeyPair accountKeyPair = KeyPairUtils.createKeyPair();
saveKeyPair(accountKeyPair, new File("account-key.pem"));
AccountBuilder accountBuilder = new AccountBuilder();
accountBuilder.useKeyPair(accountKeyPair);
accountBuilder.addContact("mailto:" + properties.getEmail());
accountBuilder.agreeToTermsOfService();
this.account = accountBuilder.create(session);
logger.info("Created new ACME account: {}", properties.getEmail());
}
@Scheduled(cron = "0 0 3 * * ?") // Run daily at 3 AM
public void checkCertificateRenewal() {
try {
for (String domain : properties.getDomains()) {
if (needsRenewal(domain)) {
renewCertificate(domain);
}
}
} catch (Exception e) {
logger.error("Certificate renewal check failed", e);
}
}
private boolean needsRenewal(String domain) {
File certFile = getCertificateFile(domain);
if (!certFile.exists()) {
return true;
}
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
try (FileInputStream fis = new FileInputStream(certFile)) {
X509Certificate cert = (X509Certificate) cf.generateCertificate(fis);
Date expiryDate = cert.getNotAfter();
long daysUntilExpiry = ChronoUnit.DAYS.between(
Instant.now(), expiryDate.toInstant());
return daysUntilExpiry <= properties.getRenewalDays();
}
} catch (Exception e) {
logger.warn("Failed to check certificate expiry for domain: {}", domain, e);
return true;
}
}
public void renewCertificate(String domain) {
try {
File keyFile = getKeyFile(domain);
File certFile = getCertificateFile(domain);
// Request new certificate
Order order = account.newOrder().domains(domain).create();
performHttpChallenge(order, domain);
order.execute();
// Wait for validation
waitForOrderCompletion(order);
// Download and save certificate
Certificate certificate = order.getCertificate();
X509Certificate x509Cert = certificate.getCertificate();
saveCertificate(x509Cert, certFile);
logger.info("Successfully renewed certificate for domain: {}", domain);
} catch (Exception e) {
logger.error("Failed to renew certificate for domain: {}", domain, e);
throw new RuntimeException("Certificate renewal failed", e);
}
}
private void performHttpChallenge(Order order, String domain) throws AcmeException {
Challenge challenge = order.getAuthorizations().stream()
.flatMap(auth -> auth.findChallenge(HttpChallenge.TYPE).stream())
.findFirst()
.orElseThrow(() -> new AcmeException("No HTTP challenge found"));
HttpChallenge httpChallenge = (HttpChallenge) challenge;
// Register challenge with controller
challengeController.registerChallenge(
httpChallenge.getToken(),
httpChallenge.getAuthorization());
// Trigger challenge
httpChallenge.trigger();
logger.info("Triggered HTTP challenge for domain: {}", domain);
}
private void waitForOrderCompletion(Order order) throws AcmeException, InterruptedException {
for (int i = 0; i < 20; i++) {
if (order.getStatus() == Status.VALID) {
return;
}
if (order.getStatus() == Status.INVALID) {
throw new AcmeException("Order validation failed");
}
Thread.sleep(3000L);
order.update();
}
throw new AcmeException("Order timed out");
}
private File getKeyFile(String domain) {
return new File(domain + "-key.pem");
}
private File getCertificateFile(String domain) {
return new File(domain + "-cert.pem");
}
private void saveCertificate(X509Certificate certificate, File file) throws IOException {
try (FileWriter writer = new FileWriter(file)) {
writer.write("-----BEGIN CERTIFICATE-----\n");
writer.write(Base64.getEncoder().encodeToString(certificate.getEncoded()));
writer.write("\n-----END CERTIFICATE-----\n");
}
}
private KeyPair loadKeyPair(File file) throws IOException, GeneralSecurityException {
// Implementation for loading key pair from file
// This is a simplified version - use proper key storage in production
return KeyPairUtils.readKeyPair(new FileReader(file));
}
private void saveKeyPair(KeyPair keyPair, File file) throws IOException {
// Implementation for saving key pair to file
KeyPairUtils.writeKeyPair(keyPair, new FileWriter(file));
}
}
Application Configuration
application.yml:
acme: enabled: true use-staging: false # Use production in real deployment email: [email protected] domains: - myapp.com - api.myapp.com key-store-password: changeit renewal-days: 30 server: port: 443 ssl: key-store: file:myapp.com-keystore.p12 key-store-password: changeit key-store-type: PKCS12 key-alias: myapp
Docker Integration
Dockerfile:
FROM eclipse-temurin:17-jre # Install acme4j and application COPY target/myapp.jar /app/myapp.jar COPY acme-certs/ /app/certs/ # Create volume for certificate storage VOLUME /app/certs # Expose HTTP port for ACME challenges EXPOSE 80 EXPOSE 443 CMD ["java", "-jar", "/app/myapp.jar"]
Best Practices for Production
- Use Staging for Testing: Always test with Let's Encrypt staging environment first
- Implement Proper Error Handling: Handle rate limits and validation failures gracefully
- Secure Key Storage: Use proper key management for account and certificate keys
- Monitor Certificate Expiry: Implement alerts for certificate renewal failures
- Backup Certificates: Regularly backup issued certificates and keys
@Component
public class CertificateHealthIndicator implements HealthIndicator {
private final AcmeCertificateManager certificateManager;
private final AcmeProperties properties;
public CertificateHealthIndicator(AcmeCertificateManager certificateManager,
AcmeProperties properties) {
this.certificateManager = certificateManager;
this.properties = properties;
}
@Override
public Health health() {
Map<String, Object> details = new HashMap<>();
for (String domain : properties.getDomains()) {
try {
File certFile = new File(domain + "-cert.pem");
if (!certFile.exists()) {
return Health.down()
.withDetail(domain, "Certificate file missing")
.build();
}
CertificateFactory cf = CertificateFactory.getInstance("X.509");
try (FileInputStream fis = new FileInputStream(certFile)) {
X509Certificate cert = (X509Certificate) cf.generateCertificate(fis);
Date expiry = cert.getNotAfter();
long daysUntilExpiry = ChronoUnit.DAYS.between(
Instant.now(), expiry.toInstant());
details.put(domain + ".expiry", expiry);
details.put(domain + ".daysUntilExpiry", daysUntilExpiry);
if (daysUntilExpiry < 7) {
return Health.down()
.withDetail(domain, "Certificate expires soon: " + daysUntilExpiry + " days")
.build();
}
}
} catch (Exception e) {
return Health.down(e).withDetail(domain, "Certificate check failed").build();
}
}
return Health.up().withDetails(details).build();
}
}
Conclusion
Integrating Let's Encrypt ACME into Java applications provides a robust, automated solution for TLS certificate management. By leveraging libraries like acme4j and integrating with Spring Boot, Java developers can eliminate manual certificate operations while ensuring their applications always have valid, up-to-date certificates.
This approach is particularly valuable for microservices architectures, containerized deployments, and any environment where certificates need to be managed at scale. The automated renewal process ensures continuous security without operational overhead, making TLS encryption accessible and manageable for Java applications of all sizes.
Java Logistics, Shipping Integration & Enterprise Inventory Automation (Tracking, ERP, RFID & Billing Systems)
https://macronepal.com/blog/aftership-tracking-in-java-enterprise-package-visibility/
Explains how to integrate AfterShip tracking services into Java applications to provide real-time shipment visibility, delivery status updates, and centralized tracking across multiple courier services.
https://macronepal.com/blog/shipping-integration-using-fedex-api-with-java-for-logistics-automation/
Explains how to integrate the FedEx API into Java systems to automate shipping tasks such as creating shipments, calculating delivery costs, generating shipping labels, and tracking packages.
https://macronepal.com/blog/shipping-and-logistics-integrating-ups-apis-with-java-applications/
Explains UPS API integration in Java to enable automated shipping operations including rate calculation, shipment scheduling, tracking, and delivery confirmation management.
https://macronepal.com/blog/generating-and-reading-qr-codes-for-products-in-java/
Explains how Java applications generate and read QR codes for product identification, tracking, and authentication, supporting faster inventory handling and product verification processes.
https://macronepal.com/blog/designing-a-robust-pick-and-pack-workflow-in-java/
Explains how to design an efficient pick-and-pack workflow in Java warehouse systems, covering order processing, item selection, packaging steps, and logistics preparation to improve fulfillment efficiency.
https://macronepal.com/blog/rfid-inventory-management-system-in-java-a-complete-guide/
Explains how RFID technology integrates with Java applications to automate inventory tracking, reduce manual errors, and enable real-time stock monitoring in warehouses and retail environments.
https://macronepal.com/blog/erp-integration-with-odoo-in-java/
Explains how Java applications connect with Odoo ERP systems to synchronize inventory, orders, customer records, and financial data across enterprise systems.
https://macronepal.com/blog/automated-invoice-generation-creating-professional-excel-invoices-with-apache-poi-in-java/
Explains how to automatically generate professional Excel invoices in Java using Apache POI, enabling structured billing documents and automated financial record creation.
https://macronepal.com/blog/enterprise-financial-integration-using-quickbooks-api-in-java-applications/
Explains QuickBooks API integration in Java to automate financial workflows such as invoice management, payment tracking, accounting synchronization, and financial reporting.