examples: add alb-tls-examples showcase #33

Merged
mauritz.uphoff merged 4 commits from examples/alb-tls-examples into main 2026-06-16 07:45:47 +00:00
85 changed files with 5298 additions and 1 deletions

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
version: 2
updates:
- package-ecosystem: "github-actions"

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: "Default CI"
on:

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: "Mirror to Public GitHub"
on:

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# .pre-commit-config.yaml
# To use this file, install pre-commit (https://pre-commit.com):
# pip install pre-commit
@ -52,5 +66,5 @@ repos:
# Requires `addlicense` to be installed locally (go install github.com/google/addlicense@latest)
entry: addlicense -c "Schwarz Digits Cloud GmbH & Co. KG" -l apache
language: system
types_or: [terraform, python, go, javascript]
types_or: [terraform, python, go, javascript, yaml, json]
pass_filenames: true

View file

@ -0,0 +1,9 @@
# Maintainers
General maintainers:
- Sven Schmidt (sven.schmidt@digits.schwarz)
This example is actively maintained. The owner is responsible for reviewing and updating dependencies and functionalities on a monthly basis.
For questions, issues, or feature requests, please email general maintainers.
Please include the BP name and version in your request. We will track your request as an issue.

View file

@ -0,0 +1,175 @@
# alb-tls-examples
A collection of STACKIT Application Load Balancer (ALB) showcases with different TLS strategies — from self-signed to Let's Encrypt, from a single VM to Kubernetes.
Each subfolder is a self-contained, runnable Terraform showcase with its own README, state, and variables. The showcases are intentionally independent — no shared state, no shared modules.
---
## Overview
```
alb-tls-examples/
├── vm-alb-self-signed-cert/ ← Starting point: 1 VM + ALB + Self-Signed Cert (Terraform)
├── vm-alb-certbot-letsencrypt/ ← Production: VM + ALB + Let's Encrypt via certbot + ACME DNS-01
└── alb-k8s/ ← Kubernetes: SKE + cert-manager + Let's Encrypt
```
---
## Showcase Comparison
| | `vm-alb-self-signed-cert` | `vm-alb-certbot-letsencrypt` | `alb-k8s` |
| ------------------------- | ---------------------------- | ----------------------------- | ----------------------------- |
| **Goal** | Getting started / quickstart | Production-grade | Kubernetes path |
| **Certificate** | Self-signed (Terraform) | Let's Encrypt (certbot) | Let's Encrypt (cert-manager) |
| **Backend** | 1 VM + Docker nginx | 1 VM + Docker nginx | SKE cluster + nginx Pod |
| **Terraform** | Yes | Yes (Phase 1) | Yes |
| **Docker** | Yes (nginx) | Yes (nginx + certbot) | No |
| **Kubernetes** | No | No | Yes (SKE) |
| **Auto-renewal** | No (re-apply) | Yes (cron on VM) | Yes (cert-manager) |
| **External dependencies** | None | Let's Encrypt, DNS delegation | Let's Encrypt, DNS delegation |
| **Time to HTTPS** | ~5 min | ~20 min | ~20 min |
---
## Learning Path
**1. [`vm-alb-self-signed-cert/`](vm-alb-self-signed-cert/README.md)**
- How does a STACKIT ALB work?
- How is a TLS certificate attached to the ALB?
- How does the ALB terminate HTTPS and forward to a backend?
**2. [`vm-alb-certbot-letsencrypt/`](vm-alb-certbot-letsencrypt/README.md)**
- How do I replace a self-signed cert with a trusted one?
- How does the ACME DNS-01 challenge work with STACKIT DNS?
- How does automatic certificate renewal work via certbot in Docker?
**3. [`alb-k8s/`](alb-k8s/README.md)**
- How does the same work on Kubernetes?
- How do cert-manager, Ingress, and STACKIT SKE interact?
---
## Common Prerequisites
| Requirement | Details |
| ----------------------- | -------------------------------------------------------- |
| STACKIT account | Access to the STACKIT Portal |
| Terraform | >= 1.5.7 (recommended: use `tfenv`) |
| STACKIT CLI | For image UUIDs, project IDs, debugging |
| SSH key pair | Ed25519 or RSA — only the public key goes into Terraform |
| STACKIT service account | JSON key with the roles required for the showcase |
| STACKIT Object Storage | For the S3-compatible Terraform remote state backend |
### Create a service account
```bash
stackit iam service-account create \
--project-id <project-id> \
--name "tf-workshop-sa"
mkdir -p keys
stackit iam service-account key create \
--project-id <project-id> \
--service-account-email <sa-email> \
--output-format json > keys/sa-key.json
```
### Useful CLI commands
```bash
# List available Debian 12 images
stackit image list --all --project-id <project-id>
# List available machine types
stackit server machine-type list --project-id <project-id>
# List projects
stackit project list
# Find your egress IP (for admin_cidr)
curl -s https://ifconfig.schwarz
```
---
## Showcase Descriptions
### [`vm-alb-self-signed-cert/`](vm-alb-self-signed-cert/README.md)
**Introductory showcase — recommended starting point.**
A single Debian 12 VM with Docker and `nginx:alpine` behind a STACKIT Application Load Balancer. The TLS certificate is self-signed and fully managed by Terraform — no external tools, no DNS delegation required.
```
Internet → ALB (HTTPS :443, Self-Signed Cert) → VM (Docker nginx :80)
```
- Terraform generates RSA key + self-signed cert (`hashicorp/tls` provider)
- Terraform uploads the certificate via `stackit_alb_certificate`
- The ALB terminates TLS, the VM receives plain HTTP
- STACKIT DNS Zone + A-Record created as part of the same apply
**When to use:** When you want to understand the ALB + TLS mechanism without extra complexity.
---
### [`vm-alb-certbot-letsencrypt/`](vm-alb-certbot-letsencrypt/README.md)
**Production showcase with automatic certificate renewal.**
Same infrastructure as `vm-alb-self-signed-cert`, but with a full ACME pipeline on the VM. Terraform provisions the ALB without a certificate; a certbot Docker container then issues and renews Let's Encrypt certificates via DNS-01 challenge.
```
Phase 1 (Terraform): VM + ALB (HTTP + target) + DNS Zone
Phase 2 (certbot): ACME DNS-01 → Let's Encrypt Cert → ALB HTTPS listener
Phase 3+ (cron): Monthly automatic renewal
```
- Requires a delegated DNS zone (set NS records at your registrar)
- `lifecycle { ignore_changes = [listeners] }` allows certbot to update the ALB outside of Terraform
**When to use:** When you want a production-realistic, fully automated certificate lifecycle.
---
### [`alb-k8s/`](alb-k8s/README.md)
**Kubernetes showcase with STACKIT SKE and cert-manager.**
Terraform creates a STACKIT Kubernetes Engine (SKE) cluster. nginx is deployed as a Kubernetes Deployment, the STACKIT NLB acts as the ingress point. cert-manager handles automatic certificate management via Let's Encrypt DNS-01 with STACKIT DNS.
```
Internet → STACKIT NLB (L4, auto) → Traefik IC (L7, in-cluster) → nginx Pod
cert-manager (Let's Encrypt DNS-01)
```
**When to use:** When you want to show how TLS works on a Kubernetes-based platform with STACKIT.
---
## Common Architecture Principles
**TLS termination at the load balancer**
The ALB (or NLB + Traefik IC on Kubernetes) terminates HTTPS. Backend VMs or pods only receive plain HTTP on port 80 over the private network.
**Infrastructure as Code**
All resources are managed via Terraform. No manual clicking in the portal.
**Gitignored secrets**
`terraform.tfvars`, `backend.conf`, and `keys/` are gitignored in every showcase. No secret ends up in the repository.
---
## References
- [STACKIT Terraform Provider](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs)
- [STACKIT Developer Documentation](https://docs.stackit.cloud)
- [STACKIT CLI](https://github.com/stackitcloud/stackit-cli)
- [hashicorp/tls Provider](https://registry.terraform.io/providers/hashicorp/tls/latest/docs)
- [cert-manager](https://cert-manager.io/docs/)

View file

@ -0,0 +1,32 @@
# Terraform
terraform/.terraform/
terraform/.terraform.lock.hcl
terraform/terraform.tfstate
terraform/terraform.tfstate.backup
terraform/*.tfstate*
terraform/*.tfvars
terraform/backend.conf
# Kubeconfig (contains cluster credentials)
.kubeconfig
kubeconfig
*.kubeconfig
# STACKIT Service Account Keys (sensitive)
keys/
secrets/
sa.json
*.sa.json
# Local environment files
.env
.env.local
# macOS
.DS_Store
# Editor
.idea/
.vscode/
*.swp
*.swo

View file

@ -0,0 +1,280 @@
# SKE TLS Showcase — NLB + Traefik + cert-manager
Deploys a STACKIT SKE cluster with a Traefik Ingress Controller behind an auto-provisioned NLB.
TLS is handled in-cluster by cert-manager (Let's Encrypt DNS-01 via STACKIT webhook) — no ALB involved.
Serves as a conceptual baseline for the upcoming STACKIT ALB integration on Kubernetes.
---
## Architecture
```mermaid
sequenceDiagram
participant User
participant TF as Terraform / STACKIT API
participant K8s as Kubernetes / SKE
participant CM as cert-manager
participant LE as Let's Encrypt
Note over User,TF: Phase 1 — terraform apply
User->>TF: create Project + SKE Cluster + DNS Zone
TF-->>User: cluster_endpoint · zone_id
Note over User,K8s: Phase 2 — deploy.sh
User->>K8s: install Traefik Ingress Controller (Helm)
K8s-->>User: nlb_ip (auto-provisioned by SKE)
User->>TF: create DNS A-Record (domain → nlb_ip)
TF-->>User: zone ready
User->>K8s: install cert-manager + STACKIT webhook (Helm)
User->>K8s: apply SA Secret + ClusterIssuer
User->>K8s: deploy nginx app + Ingress + Certificate
Note over K8s,LE: Phase 3 — cert-manager (automatic)
CM->>LE: request certificate (DNS-01 challenge)
LE-->>CM: .crt · .pem
CM->>K8s: store in Secret nginx-tls
K8s-->>User: ✅ https://your-domain ready
```
```
Client → STACKIT DNS → STACKIT NLB (auto) → Traefik Ingress Controller → nginx Pod
└── TLS: cert-manager + Let's Encrypt
```
---
## Overview
| Component | Description |
| ---------------------- | ------------------------------------------------------------------------ |
| Resource hierarchy | Project under an existing STACKIT organisation |
| Kubernetes | STACKIT Kubernetes Engine (SKE) cluster |
| Network | NLB auto-provisioned by SKE when Traefik LoadBalancer Service is created |
| Ingress | Traefik Ingress Controller (Helm) |
| Certificate automation | cert-manager + STACKIT DNS-01 webhook (Helm) |
| DNS | STACKIT primary zone + A-record pointing to the NLB IP |
| Application | nginx Deployment + Service + Ingress + Certificate manifest |
| In this showcase | Not in this showcase |
| ----------------------------------- | -------------------------------------------------- |
| cert-manager + Let's Encrypt DNS-01 | STACKIT ALB Ingress Controller (not yet available) |
| SKE cluster via Terraform | Self-signed or manually managed certs |
| Traefik Ingress Controller | ALB direct Kubernetes integration |
| STACKIT DNS-01 webhook | Multiple namespaces / production Helm values |
---
## Prerequisites
| Tool | Version |
| ----------- | ------- |
| Terraform | >= 1.3 |
| helm | >= 3.x |
| kubectl | >= 1.28 |
| STACKIT CLI | >= 0.64 |
| jq | any |
### Required STACKIT permissions
| Service Account | Permissions | Used by |
| --------------- | -------------------------------------- | --------------------------------- |
| Terraform SA | SKE Admin, DNS Admin, Resource Manager | `terraform apply` |
| DNS SA | DNS Admin (project-scoped) | DNS-01 challenge via cert-manager |
Place the DNS SA key at `terraform/keys/sa-key.json`. `deploy.sh` creates the Kubernetes Secret from this file automatically.
### Create service accounts
```bash
# Terraform SA
stackit iam service-account create \
--project-id <project-id> \
--name "tf-workshop-sa"
mkdir -p terraform/keys
stackit iam service-account key create \
--project-id <project-id> \
--service-account-email <sa-email> \
--output-format json > terraform/keys/sa-key.json
# DNS SA (for cert-manager webhook)
stackit iam service-account create \
--project-id <project-id> \
--name "dns-certmanager-sa"
stackit iam service-account key create \
--project-id <project-id> \
--service-account-email <dns-sa-email> \
--output-format json > terraform/keys/sa-key.json
```
---
## Deployment
### 1. Configure credentials
```bash
cd terraform
cp backend.conf.example backend.conf # fill in access_key + secret_key
cp terraform.tfvars.example terraform.tfvars # fill in all values
```
### 2. Provision infrastructure
```bash
terraform init -backend-config=backend.conf
terraform plan
terraform apply
```
### 3. Set kubeconfig
```bash
export KUBECONFIG=$(pwd)/../.kubeconfig
kubectl get nodes
```
### 4. Deploy cluster components
`deploy.sh` sets the DNS A record, installs Helm charts, and applies manifests.
The DNS SA key must exist at `terraform/keys/sa-key.json` (DNS Admin role required).
```bash
cd .. && bash scripts/deploy.sh
```
---
## Validation
```bash
# Monitor certificate issuance
kubectl describe certificate nginx-tls -n nginx-showcase
# Test HTTPS once the certificate is Ready
curl https://<app_hostname>.<dns_zone_fqdn>
# Verify NLB IP is wired to DNS
dig <app_hostname>.<dns_zone_fqdn>
```
---
## TLS Flow (DNS-01)
1. cert-manager creates an ACME order with Let's Encrypt
2. Let's Encrypt requests a `_acme-challenge` TXT record
3. STACKIT webhook creates the TXT record via the STACKIT DNS API
4. Let's Encrypt validates and issues the certificate
5. cert-manager stores the certificate in Secret `nginx-tls`
6. Traefik reads the secret and terminates TLS
7. cert-manager auto-renews 30 days before expiry
---
## File Structure
```
alb-k8s/
├── terraform/
│ ├── 00-backend.tf # S3 backend declaration
│ ├── 00-provider.tf # Provider versions + STACKIT provider config
│ ├── 01-variables.tf
│ ├── 02-resource-hierarchy.tf # Creates STACKIT folder + project
│ ├── 03-network.tf # NLB is auto-provisioned by SKE (no explicit resources)
│ ├── 04-compute.tf # SKE cluster + kubeconfig
│ ├── 05-dns.tf # DNS zone (A record set by deploy.sh)
│ ├── 06-outputs.tf
│ ├── backend.conf # Backend credentials (gitignored)
│ ├── backend.conf.example
│ ├── terraform.tfvars # Active config (gitignored)
│ └── terraform.tfvars.example
├── kubernetes/
│ ├── cert-manager/
│ │ ├── 00-stackit-sa-secret.yaml # SA secret template (deploy.sh creates from keys/)
│ │ ├── 01-cluster-issuer.yaml # ClusterIssuer: Let's Encrypt production
│ │ └── 02-certificate.yaml
│ └── nginx/
│ ├── 00-namespace.yaml
│ ├── 01-deployment.yaml
│ ├── 02-service.yaml
│ └── 03-ingress.yaml
├── docs/
│ └── architecture.md # Deployment sequence diagram + component overview
├── scripts/
│ └── deploy.sh
└── terraform/keys/ # SA key JSON files — gitignored
```
---
## Security
| File | Git status | Contains |
| ---------------------------- | ---------- | -------------------------- |
| `terraform/terraform.tfvars` | gitignored | Sensitive configuration |
| `terraform/backend.conf` | gitignored | Object Storage access keys |
| `terraform/keys/` | gitignored | Service account JSON keys |
- SSH access is not directly exposed — cluster access via kubeconfig only
- Never commit SA key JSON files to the repository
- The DNS SA key is the most sensitive credential — it has write access to your DNS zone
---
## Cleanup
```bash
cd terraform && terraform destroy
```
> **Note:** After `terraform destroy`, STACKIT projects remain in "Pending Deletion" for up to 7 days. This is expected STACKIT platform behaviour.
---
## Troubleshooting
**Certificate stuck in Pending:**
```bash
kubectl describe certificate nginx-tls -n nginx-showcase
kubectl describe certificaterequest -n nginx-showcase
kubectl logs -n cert-manager deploy/cert-manager
kubectl logs -n cert-manager -l app=stackit-cert-manager-webhook
```
Common causes: wrong `projectId` in ClusterIssuer, SA missing DNS permissions, DNS zone in wrong project.
**Traefik has no external IP:**
```bash
kubectl get svc -n traefik traefik
```
Wait 23 minutes — STACKIT provisions the NLB asynchronously.
**kubeconfig expired:**
```bash
cd terraform && terraform apply
export KUBECONFIG=$(pwd)/../.kubeconfig
```
---
## References
- [Full architecture details](docs/architecture.md)
- [STACKIT Terraform Provider](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs)
- [STACKIT CLI](https://github.com/stackitcloud/stackit-cli)
- [STACKIT Developer Documentation](https://docs.stackit.cloud)
- [cert-manager](https://cert-manager.io/docs/)
- [stackit-cert-manager-webhook](https://github.com/stackitcloud/stackit-cert-manager-webhook)

View file

@ -0,0 +1,144 @@
# Architecture: alb-k8s
## Deployment Sequence
```mermaid
sequenceDiagram
participant User
participant TF as Terraform / STACKIT API
participant K8s as Kubernetes / SKE
participant CM as cert-manager
participant LE as Let's Encrypt
Note over User,TF: Phase 1 — terraform apply
User->>TF: create Project + SKE Cluster + DNS Zone
TF-->>User: cluster_endpoint · zone_id
Note over User,K8s: Phase 2 — deploy.sh
User->>K8s: install Traefik Ingress Controller (Helm)
K8s-->>User: nlb_ip (auto-provisioned by SKE)
User->>TF: create DNS A-Record (domain → nlb_ip)
TF-->>User: zone ready
User->>K8s: install cert-manager + STACKIT webhook (Helm)
User->>K8s: apply SA Secret + ClusterIssuer
User->>K8s: deploy nginx app + Ingress + Certificate
Note over K8s,LE: Phase 3 — cert-manager (automatic)
CM->>LE: request certificate (DNS-01 challenge)
LE-->>CM: .crt · .pem
CM->>K8s: store in Secret nginx-tls
K8s-->>User: ✅ https://your-domain ready
```
---
# SKE with TLS — NLB + Traefik + cert-manager
## Traffic Flow
```
Client
│ DNS lookup: nginx.alb-k8s-showcase.stackit.gg
STACKIT DNS
│ resolves to NLB public IP (set by deploy.sh)
│ HTTPS :443
STACKIT NLB (L4, provisioned automatically by SKE)
│ TCP passthrough
Traefik Ingress Controller (L7, in-cluster)
│ TLS termination (cert from Secret: nginx-tls)
│ Host-based routing + HTTP→HTTPS redirect
ClusterIP Service: nginx (namespace: nginx-showcase)
Pod: nginxinc/nginx-unprivileged:1.27-alpine
```
**NLB provisioning:** STACKIT creates the NLB automatically when the Traefik
`Service` of type `LoadBalancer` is applied. The assigned IP is dynamic; `deploy.sh`
reads it and creates the DNS A record via the STACKIT CLI.
---
## TLS Certificate Flow (DNS-01)
```
cert-manager stackit-cert-manager-webhook STACKIT DNS API Let's Encrypt
│ │ │ │
│── new Certificate ────────►│ │ │
│ │ │ │
│◄── ACME order ────────────────────────────────────────────────────────│
│ │ │ │
│── solve DNS-01 ───────────►│ │ │
│ │── create TXT record ──►│ │
│ │ _acme-challenge.nginx │ │
│ │ .alb-k8s-showcase │ │
│ │ .stackit.gg │ │
│ │ │ │
│◄── challenge ready ──────────────────────────────────────────────────│
│ │◄── verify TXT ─────────│ │
│ │ │ │
│◄── certificate issued ────────────────────────────────────────────────│
│ │ │ │
│── delete TXT record ──────►│── delete TXT ─────────►│ │
│ │ │ │
│── store in Secret: nginx-tls (namespace: nginx-showcase)
```
cert-manager renews automatically 30 days before expiry.
---
## Component Responsibility
| Component | Provisioned by | Purpose |
| -------------------------- | -------------------------------------- | -------------------------------------------------- |
| STACKIT Folder + Project | Terraform (`02-resource-hierarchy.tf`) | Resource boundary |
| SKE Cluster | Terraform (`04-compute.tf`) | Kubernetes control plane + nodes |
| DNS Zone | Terraform (`05-dns.tf`) | `alb-k8s-showcase.stackit.gg` |
| DNS A Record | `deploy.sh` (stackit CLI) | `nginx.alb-k8s-showcase.stackit.gg → NLB IP` |
| STACKIT NLB | STACKIT (automatic on LB Service) | L4 load balancer |
| Traefik Ingress Controller | Helm (`deploy.sh`) | L7 routing + TLS termination + HTTP→HTTPS redirect |
| cert-manager | Helm (`deploy.sh`) | Certificate lifecycle |
| STACKIT DNS webhook | Helm (`deploy.sh`) | DNS-01 solver |
| SA Secret | `deploy.sh` (kubectl) | Webhook authenticates against STACKIT API |
| nginx Pod | kubectl (`deploy.sh`) | Demo workload |
---
## Namespace Layout
```
traefik
└── Deployment: traefik (Traefik IC)
└── Service: traefik (LoadBalancer → NLB)
cert-manager
└── Deployment: cert-manager
└── Deployment: cert-manager-webhook
└── Deployment: stackit-cert-manager-webhook
└── Secret: stackit-sa-authentication (STACKIT SA key)
└── ClusterIssuer: letsencrypt-prod
nginx-showcase
└── Deployment: nginx
└── Service: nginx (ClusterIP)
└── Certificate: nginx-tls
└── Secret: nginx-tls (managed by cert-manager)
└── Ingress: nginx
```
---

View file

@ -0,0 +1,47 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Reference template only. Do NOT commit with real credentials.
# deploy.sh creates this secret automatically from terraform/keys/sa-key.json.
# To create manually: kubectl create secret generic stackit-sa-authentication \
# --namespace cert-manager --from-file=sa.json=/path/to/sa.json
apiVersion: v1
kind: Secret
metadata:
name: stackit-sa-authentication
namespace: cert-manager
labels:
app.kubernetes.io/part-of: alb-k8s-showcase
app.kubernetes.io/managed-by: kubectl
app.kubernetes.io/component: cert-manager
type: Opaque
stringData:
sa.json: |
{
"id": "<SERVICE_ACCOUNT_KEY_ID>",
"publicKey": "-----BEGIN PUBLIC KEY-----\n<PUBLIC_KEY>\n-----END PUBLIC KEY-----",
"createdAt": "<ISO_TIMESTAMP>",
"validUntil": "<ISO_TIMESTAMP>",
"keyType": "USER_MANAGED",
"keyOrigin": "GENERATED",
"keyAlgorithm": "RSA_2048",
"active": true,
"credentials": {
"kid": "<KID>",
"iss": "<ISS>",
"sub": "<SUB>",
"aud": "<AUD>",
"privateKey": "-----BEGIN PRIVATE KEY-----\n<PRIVATE_KEY>\n-----END PRIVATE KEY-----" # gitleaks:allow
}
}

View file

@ -0,0 +1,37 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
labels:
app.kubernetes.io/part-of: alb-k8s-showcase
app.kubernetes.io/managed-by: kubectl
app.kubernetes.io/component: cert-manager
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: <ACME_CONTACT_EMAIL>
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- dns01:
webhook:
solverName: stackit
groupName: acme.stackit.de
config:
projectId: <STACKIT_PROJECT_ID>
apiBasePath: https://dns.api.stackit.cloud
acmeTxtRecordTTL: 60

View file

@ -0,0 +1,35 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: nginx-tls
namespace: nginx-showcase
labels:
app.kubernetes.io/name: nginx
app.kubernetes.io/instance: nginx-showcase
app.kubernetes.io/part-of: alb-k8s-showcase
app.kubernetes.io/managed-by: kubectl
app.kubernetes.io/component: cert-manager
spec:
secretName: nginx-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
commonName: "nginx.alb-k8s-showcase.stackit.gg"
duration: 2160h0m0s
renewBefore: 720h0m0s
dnsNames:
- "nginx.alb-k8s-showcase.stackit.gg"

View file

@ -0,0 +1,21 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: v1
kind: Namespace
metadata:
name: nginx-showcase
labels:
app.kubernetes.io/part-of: alb-k8s-showcase
app.kubernetes.io/managed-by: kubectl

View file

@ -0,0 +1,77 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: nginx-showcase
labels:
app.kubernetes.io/name: nginx
app.kubernetes.io/instance: nginx-showcase
app.kubernetes.io/part-of: alb-k8s-showcase
app.kubernetes.io/managed-by: kubectl
app.kubernetes.io/component: web
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: nginx
app.kubernetes.io/instance: nginx-showcase
template:
metadata:
labels:
app.kubernetes.io/name: nginx
app.kubernetes.io/instance: nginx-showcase
app.kubernetes.io/part-of: alb-k8s-showcase
app.kubernetes.io/component: web
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
containers:
- name: nginx
image: nginxinc/nginx-unprivileged:1.27-alpine
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 30
failureThreshold: 3
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "100m"
memory: "64Mi"
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault

View file

@ -0,0 +1,35 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: v1
kind: Service
metadata:
name: nginx
namespace: nginx-showcase
labels:
app.kubernetes.io/name: nginx
app.kubernetes.io/instance: nginx-showcase
app.kubernetes.io/part-of: alb-k8s-showcase
app.kubernetes.io/managed-by: kubectl
app.kubernetes.io/component: web
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: nginx
app.kubernetes.io/instance: nginx-showcase
ports:
- name: http
protocol: TCP
port: 80
targetPort: http

View file

@ -0,0 +1,41 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nginx
namespace: nginx-showcase
labels:
app.kubernetes.io/name: nginx
app.kubernetes.io/instance: nginx-showcase
app.kubernetes.io/part-of: alb-k8s-showcase
app.kubernetes.io/managed-by: kubectl
spec:
ingressClassName: traefik
rules:
- host: "nginx.alb-k8s-showcase.stackit.gg"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nginx
port:
name: http
tls:
- hosts:
- "nginx.alb-k8s-showcase.stackit.gg"
secretName: nginx-tls

View file

@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
KUBECONFIG="$(realpath "${KUBECONFIG:-${REPO_ROOT}/.kubeconfig}")"
export KUBECONFIG
if [[ ! -f "${KUBECONFIG}" ]]; then
echo "Error: kubeconfig not found at ${KUBECONFIG}"
exit 1
fi
echo "Using kubeconfig: ${KUBECONFIG}"
HELM="helm --kubeconfig ${KUBECONFIG}"
KUBECTL="kubectl --kubeconfig ${KUBECONFIG}"
TERRAFORM_DIR="${REPO_ROOT}/terraform"
echo "==> [1/6] Traefik Ingress Controller"
helm repo add traefik https://traefik.github.io/charts
helm repo update traefik
${HELM} upgrade --install traefik traefik/traefik \
--namespace traefik \
--create-namespace \
--set "ports.web.redirectTo.port=websecure" \
--set service.type=LoadBalancer \
--wait --timeout 5m
echo " Waiting for LoadBalancer IP..."
for i in $(seq 1 24); do
LB_IP=$(${KUBECTL} get svc -n traefik traefik \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || true)
[[ -n "${LB_IP}" ]] && break
sleep 5
done
if [[ -z "${LB_IP:-}" ]]; then
echo "Error: LoadBalancer IP not assigned after 2 minutes."
exit 1
fi
echo " LoadBalancer IP: ${LB_IP}"
echo "==> [2/6] DNS A record"
PROJECT_ID=$(cd "${TERRAFORM_DIR}" && terraform output -raw project_id)
ZONE_ID=$(cd "${TERRAFORM_DIR}" && terraform output -raw dns_zone_id)
APP_FQDN=$(cd "${TERRAFORM_DIR}" && terraform output -raw app_fqdn)
APP_HOSTNAME="${APP_FQDN%%.*}"
RS_ID=$(stackit dns record-set list \
--project-id "${PROJECT_ID}" \
--zone-id "${ZONE_ID}" \
-o json 2>/dev/null | \
jq -r --arg name "${APP_FQDN}." '.[] | select(.name==$name and .type=="A") | .id' 2>/dev/null || true)
if [[ -n "${RS_ID}" ]]; then
echo " DNS A record already exists, skipping."
else
stackit dns record-set create \
--project-id "${PROJECT_ID}" \
--zone-id "${ZONE_ID}" \
--name "${APP_HOSTNAME}" \
--type A \
--record "${LB_IP}" \
--ttl 300 \
--async \
-y
echo " DNS A record created: ${APP_FQDN}${LB_IP}"
fi
echo "==> [3/6] cert-manager"
helm repo add jetstack https://charts.jetstack.io
helm repo update jetstack
if ${HELM} status cert-manager --namespace cert-manager &>/dev/null; then
echo " cert-manager already installed, skipping."
else
${HELM} install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true \
--wait --timeout 5m
fi
echo "==> [4/6] STACKIT SA secret for cert-manager webhook"
SA_KEY="${REPO_ROOT}/terraform/keys/sa-key.json"
if [[ ! -f "${SA_KEY}" ]]; then
echo "Error: SA key not found at ${SA_KEY}"
exit 1
fi
${KUBECTL} create secret generic stackit-sa-authentication \
--namespace cert-manager \
--from-file=sa.json="${SA_KEY}" \
--dry-run=client -o yaml | ${KUBECTL} apply -f -
echo "==> [5/6] STACKIT cert-manager webhook"
${KUBECTL} wait deployment/cert-manager-webhook \
--namespace cert-manager \
--for=condition=Available \
--timeout=120s
helm repo add stackit-cert-manager-webhook \
https://stackitcloud.github.io/stackit-cert-manager-webhook
helm repo update stackit-cert-manager-webhook
if ${HELM} status stackit-cert-manager-webhook --namespace cert-manager &>/dev/null; then
echo " stackit-cert-manager-webhook already installed, skipping."
else
${HELM} install stackit-cert-manager-webhook \
stackit-cert-manager-webhook/stackit-cert-manager-webhook \
--namespace cert-manager \
--set stackitSaAuthentication.enabled=true \
--wait --timeout 5m
fi
echo "==> [6/6] Kubernetes manifests"
${KUBECTL} apply -f "${REPO_ROOT}/kubernetes/cert-manager/01-cluster-issuer.yaml"
${KUBECTL} apply -f "${REPO_ROOT}/kubernetes/nginx/00-namespace.yaml"
${KUBECTL} apply -f "${REPO_ROOT}/kubernetes/nginx/01-deployment.yaml"
${KUBECTL} apply -f "${REPO_ROOT}/kubernetes/nginx/02-service.yaml"
${KUBECTL} apply -f "${REPO_ROOT}/kubernetes/cert-manager/02-certificate.yaml"
${KUBECTL} apply -f "${REPO_ROOT}/kubernetes/nginx/03-ingress.yaml"
echo ""
echo "==> Done. App: https://${APP_FQDN}"
echo " ${KUBECTL} describe certificate nginx-tls -n nginx-showcase"

View file

@ -0,0 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
terraform {
backend "s3" {}
}

View file

@ -0,0 +1,33 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
terraform {
required_version = ">= 1.3.0"
required_providers {
stackit = {
source = "stackitcloud/stackit"
version = ">= 0.97.0"
}
local = {
source = "hashicorp/local"
version = ">= 2.4.0"
}
}
}
provider "stackit" {
default_region = var.region
service_account_key_path = var.service_account_key_path
}

View file

@ -0,0 +1,98 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
variable "service_account_key_path" {
type = string
description = "Path to the STACKIT Service Account JSON key file (e.g. 'keys/sa-key.json')."
}
variable "region" {
type = string
description = "STACKIT region."
}
variable "organization_id" {
type = string
description = "STACKIT Organization ID (parent of the folder)."
}
variable "folder_name" {
type = string
description = "Name for the STACKIT folder."
}
variable "owner_email" {
type = string
description = "Project owner email address."
}
variable "project_name" {
type = string
description = "Name for the new STACKIT project."
}
variable "cluster_name" {
type = string
description = "SKE cluster name. Maximum 11 characters."
}
variable "kubernetes_version_min" {
type = string
description = "Minimum Kubernetes version. STACKIT auto-updates within this minor version."
}
variable "node_machine_type" {
type = string
description = "Worker node machine type (e.g. 'g2i.2' = 2 vCPU / 8 GB RAM)."
}
variable "node_os_name" {
type = string
description = "Node OS. SKE supports 'flatcar'."
}
variable "node_min" {
type = string
description = "Minimum node count."
}
variable "node_max" {
type = string
description = "Maximum node count."
}
variable "availability_zone" {
type = string
description = "Availability zone for the node pool."
}
variable "dns_zone_name" {
type = string
description = "STACKIT DNS zone display name."
}
variable "dns_zone_fqdn" {
type = string
description = "DNS zone FQDN (e.g. 'showcase.example.com'). Must be a domain you control."
}
variable "dns_contact_email" {
type = string
description = "Contact email for the DNS zone."
}
variable "app_hostname" {
type = string
description = "Hostname label for the nginx app (e.g. 'nginx' → nginx.showcase.example.com)."
}

View file

@ -0,0 +1,29 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
resource "stackit_resourcemanager_folder" "this" {
name = var.folder_name
parent_container_id = var.organization_id
owner_email = var.owner_email
lifecycle {
ignore_changes = [labels]
}
}
resource "stackit_resourcemanager_project" "this" {
name = var.project_name
parent_container_id = stackit_resourcemanager_folder.this.folder_id
owner_email = var.owner_email
}

View file

@ -0,0 +1,18 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# No explicit network resources needed.
# STACKIT NLB is provisioned automatically by SKE when the NGINX Ingress
# Controller Service of type LoadBalancer is created. The assigned IP is
# retrieved by deploy.sh and used to create the DNS A record.

View file

@ -0,0 +1,57 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
resource "stackit_ske_cluster" "this" {
project_id = stackit_resourcemanager_project.this.project_id
name = var.cluster_name
kubernetes_version_min = var.kubernetes_version_min
node_pools = [
{
name = "default"
machine_type = var.node_machine_type
os_name = var.node_os_name
minimum = var.node_min
maximum = var.node_max
availability_zones = [var.availability_zone]
volume_type = "storage_premium_perf1"
volume_size = "32"
}
]
maintenance = {
enable_kubernetes_version_updates = true
enable_machine_image_version_updates = true
start = "01:00:00Z"
end = "02:00:00Z"
}
network = {
control_plane = {
access_scope = "PUBLIC"
}
}
}
resource "stackit_ske_kubeconfig" "this" {
project_id = stackit_resourcemanager_project.this.project_id
cluster_name = stackit_ske_cluster.this.name
refresh = true
}
resource "local_sensitive_file" "kubeconfig" {
content = stackit_ske_kubeconfig.this.kube_config
filename = "${path.module}/../.kubeconfig"
file_permission = "0600"
}

View file

@ -0,0 +1,22 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
resource "stackit_dns_zone" "this" {
project_id = stackit_resourcemanager_project.this.project_id
name = var.dns_zone_name
dns_name = var.dns_zone_fqdn
contact_email = var.dns_contact_email
type = "primary"
default_ttl = 300
}

View file

@ -0,0 +1,63 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
output "project_id" {
value = stackit_resourcemanager_project.this.project_id
description = "STACKIT Project ID. Required for kubernetes/ manifests and cert-manager ClusterIssuer."
}
output "cluster_name" {
value = stackit_ske_cluster.this.name
description = "Name of the SKE cluster."
}
output "dns_zone_id" {
value = stackit_dns_zone.this.zone_id
description = "STACKIT DNS Zone ID."
}
output "dns_zone_fqdn" {
value = var.dns_zone_fqdn
description = "DNS zone FQDN. The app is reachable at <app_hostname>.<dns_zone_fqdn>."
}
output "app_fqdn" {
value = "${var.app_hostname}.${var.dns_zone_fqdn}"
description = "Fully qualified domain name of the nginx showcase app."
}
output "kubeconfig_path" {
value = local_sensitive_file.kubeconfig.filename
description = "Path to the generated kubeconfig."
}
output "next_steps" {
value = <<-EOT
Infrastructure deployed.
1. Set kubeconfig:
export KUBECONFIG=$(terraform output -raw kubeconfig_path)
2. Verify cluster:
kubectl get nodes
3. Note project_id for cert-manager config:
terraform output project_id
4. Deploy cluster components (sets DNS A record automatically):
cd .. && bash scripts/deploy.sh
EOT
description = "Next steps after terraform apply."
}

View file

@ -0,0 +1,71 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# A native STACKIT ALB Ingress Controller for Kubernetes does not yet exist (mid-2026).
#
# Current traffic path:
# Internet STACKIT NLB (yawol, auto-provisioned) Traefik IC Pod
#
# Target path when available:
# Internet STACKIT ALB Kubernetes Service / Ingress Pod
#
# Expected changes:
# - ingressClassName: traefik ingressClassName: <stackit-alb>
# - Traefik IC replaced by ALB as L7 termination point
# - TLS terminates at the ALB; cert-manager integration TBD
# - nginx.ingress.kubernetes.io/* annotations replaced by ALB equivalents
#
# For an external ALB in front of the cluster (available today), see ../alb/.
# resource "stackit_application_load_balancer" "this" {
# project_id = stackit_resourcemanager_project.this.project_id
# region = var.region
# name = "${var.cluster_name}-alb"
# plan_id = "p10"
# external_address = "<STACKIT_PUBLIC_IP>"
#
# listeners = [
# {
# name = "https"
# port = 443
# protocol = "PROTOCOL_HTTPS"
# https = {
# certificate_config = {
# certificate_ids = ["<STACKIT_CERT_ID>"]
# }
# }
# http = {
# hosts = [{
# host = var.app_hostname
# rules = [{ target_pool = "ske-nodeport" }]
# }]
# }
# }
# ]
#
# networks = [
# {
# network_id = "<SKE_NETWORK_ID>"
# role = "ROLE_LISTENERS_AND_TARGETS"
# }
# ]
#
# target_pools = [
# {
# name = "ske-nodeport"
# target_port = 30080
# targets = []
# }
# ]
# }

View file

@ -0,0 +1,15 @@
bucket = ""
key = ""
region = "eu01"
endpoints = {
s3 = "https://object.storage.eu01.onstackit.cloud"
}
access_key = ""
secret_key = ""
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_s3_checksum = true

View file

@ -0,0 +1,28 @@
# cp terraform.tfvars.example terraform.tfvars
# ── Auth ────────────────────────────────────────────────────────────────────
service_account_key_path = "keys/sa-key.json"
# ── STACKIT ─────────────────────────────────────────────────────────────────
region = "eu01"
# ── Resource Hierarchy ──────────────────────────────────────────────────────
organization_id = ""
folder_name = "alb-k8s-showcase"
owner_email = "firstname.lastname@example.com"
project_name = "alb-k8s-showcase"
# ── SKE Cluster ─────────────────────────────────────────────────────────────
cluster_name = "alb-k8s"
kubernetes_version_min = "1.33"
node_machine_type = "g2i.2"
node_os_name = "flatcar"
node_min = "1"
node_max = "3"
availability_zone = "eu01-1"
# ── DNS ─────────────────────────────────────────────────────────────────────
dns_zone_name = "alb-k8s-showcase"
dns_zone_fqdn = "showcase.example.com"
dns_contact_email = "firstname.lastname@example.com"
app_hostname = "nginx"

View file

@ -0,0 +1,51 @@
# ---> Terraform
# Local .terraform directories
**/.terraform/*
# .tfstate files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Exclude all .tfvars files, which are likely to contain sensitive data, such as
# password, private keys, and other secrets. These should not be part of version
# control as they are data points which are potentially sensitive and subject
# to change depending on the environment.
*.tfvars
*.tfvars.json
# Ignore override files as they are usually used to override resources locally and so
# are not checked in
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Ignore transient lock info files created by terraform apply
.terraform.tfstate.lock.info
# Include override files you do wish to add to version control using negated pattern
# !example_override.tf
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*
# Ignore CLI configuration files
.terraformrc
terraform.rc
.env
*.qcow2
.DS_Store
*.bkp
.idea
keys/*
backend.conf
# stackit-acme-alb
stackit-acme-alb/.env
stackit-acme-alb/letsencrypt_data/

View file

@ -0,0 +1,65 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/random" {
version = "3.6.3"
constraints = "3.6.3"
hashes = [
"h1:zG9uFP8l9u+yGZZvi5Te7PV62j50azpgwPunq2vTm1E=",
"zh:04ceb65210251339f07cd4611885d242cd4d0c7306e86dda9785396807c00451",
"zh:448f56199f3e99ff75d5c0afacae867ee795e4dfda6cb5f8e3b2a72ec3583dd8",
"zh:4b4c11ccfba7319e901df2dac836b1ae8f12185e37249e8d870ee10bb87a13fe",
"zh:4fa45c44c0de582c2edb8a2e054f55124520c16a39b2dfc0355929063b6395b1",
"zh:588508280501a06259e023b0695f6a18149a3816d259655c424d068982cbdd36",
"zh:737c4d99a87d2a4d1ac0a54a73d2cb62974ccb2edbd234f333abd079a32ebc9e",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:a357ab512e5ebc6d1fda1382503109766e21bbfdfaa9ccda43d313c122069b30",
"zh:c51bfb15e7d52cc1a2eaec2a903ac2aff15d162c172b1b4c17675190e8147615",
"zh:e0951ee6fa9df90433728b96381fb867e3db98f66f735e0c3e24f8f16903f0ad",
"zh:e3cdcb4e73740621dabd82ee6a37d6cfce7fee2a03d8074df65086760f5cf556",
"zh:eff58323099f1bd9a0bec7cb04f717e7f1b2774c7d612bf7581797e1622613a0",
]
}
provider "registry.terraform.io/hashicorp/tls" {
version = "4.3.0"
constraints = "~> 4.0"
hashes = [
"h1:5bCU/c+2HUh7GhclzNSH6gAuoCS4inW3obEtRAwu6WQ=",
"zh:0ab58d6f8991d436c7d2dbd89ed814709b949b07ac5a54ee53b0aec1fa772a8b",
"zh:60b347abcb56f45d97c56f14d895069cd15a83993f199777f571b79fea3642ee",
"zh:6889be32640349230de3f23856e6f04e0e9ced4a84a27d3f552fa54684448218",
"zh:73f8e1ecf7135033165fb14b7e8bf4d656f3ce13065ec35762ea0481975328c7",
"zh:94ce25ee253eca0b42cae9c856b36bca8103b6453012d1b279c3623c805f2d42",
"zh:96bc6de9fd67bc446fd11257872e1ffb1029a996ed1d65a3f6b43f6d408ad9ab",
"zh:97c609a310a51bfd504d704e036d72064a84bf0bdb36cc08cd4cc66098212b41",
"zh:a12c16e94533c5bd123f75032576b9dc91dd5d5ccd5f7cf331d0f2e1adc55cf8",
"zh:c4f014f876adf7af57188795050bda5b0029d8c7d7773031102b6c36dcf1fc21",
"zh:d9b0a21583aaa3df3a95394fb949a3c515ff71c2ff5a1fc4a73d364aa90bfca5",
"zh:da510d22f0c6d71ad19a76406f106b782448f512375787ecfabb338ed1e311a7",
"zh:f0e9447a9ce3a24cdaa113089e65663c836d8b9bfdb915a1c0284e0112cab5c0",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}
provider "registry.terraform.io/stackitcloud/stackit" {
version = "0.98.0"
constraints = "> 0.95.0"
hashes = [
"h1:/FB0wBnvmjumjykX+j90kSck6LMScDaYo1STO5Vp/kw=",
"zh:031028340fbaeeb5c4c6b1d5c6d6287a70cf253cfb89f04d462a1c0ab6237ffc",
"zh:0dde99e7b343fa01f8eefc378171fb8621bedb20f59157d6cc8e3d46c738105f",
"zh:0eee18f9a262fa58966c960f1f0863eed92cd953d0f0306ecc456b58cc2911f8",
"zh:1646966ebac0eb5d6c78ac5aa1528921d7a635f14d81300463a402c55e33cfd3",
"zh:5374ab9e5e6d837787b4f18bcf0125a1bf3ee2da40c022cc7695d6879fed111b",
"zh:6a5b9e1307055f8d358373da625ffcb4d77ec44f260d14473b10e5777380765e",
"zh:6c90090504474695ab7290d64386dd988f4fb65c90c74c9cf3a6da6226ae8a70",
"zh:8317218828f29be95ce712863646dc8968e146ec14e5ab258cb1e8f8b649245b",
"zh:9eef08e4fb7a75760f9dc8a422446f19a210ebf8177dd5aeb97444295f0120cf",
"zh:9f2147eee63feae75b96f17f3b3ebab8a29cd7164cdd08eb2bb871e5c425a77f",
"zh:b63ea754eea233292fb73d87a9810104da2bd347abf2ca0da44ac76591dcdddb",
"zh:de60bd928828a836e446f9f89e7a3bfc4e6dd73bac6827914087b34e4ad0c978",
"zh:f22d295b2e4e94ae1566e20fd752825e008a62250cf7243f1161c0bf4e986518",
"zh:f7e57bc7be2cc016983ff3ad50d2733b85e90bfaa7aa9e2192563dc9d422fb07",
]
}

View file

@ -0,0 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
terraform {
backend "s3" {}
}

View file

@ -0,0 +1,34 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
terraform {
required_version = ">= 1.5.0"
required_providers {
stackit = {
source = "stackitcloud/stackit"
version = ">= 0.98.0"
}
random = {
source = "hashicorp/random"
version = "3.6.3"
}
}
}
provider "stackit" {
default_region = var.stackit_region
service_account_key_path = var.stackit_service_account_key_path
# required for stackit_application_load_balancer (beta resource)
enable_beta_resources = true
}

View file

@ -0,0 +1,179 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Provider
variable "stackit_region" {
description = "STACKIT region (e.g. eu01)"
type = string
default = "eu01"
}
variable "stackit_service_account_key_path" {
description = "Relative path to the STACKIT service account key JSON file"
type = string
default = "keys/sa-key.json"
}
# Resource Hierarchy
variable "organization_id" {
description = "STACKIT organisation container ID — parent into which the folder is created (find in the STACKIT Portal under Organisation settings)"
type = string
}
variable "owner_email" {
description = "Email of the resource owner; used for folder and project creation (must be an existing STACKIT user in the organisation)"
type = string
}
variable "folder_name" {
description = "Name of the folder to create under the organisation"
type = string
default = "alb-certbot-workshop"
}
variable "project_name" {
description = "Name of the project to create inside the folder"
type = string
default = "alb-certbot-dev"
}
# Network
variable "network_name" {
description = "Name of the private network"
type = string
default = "alb-certbot-net"
}
variable "network_cidr" {
description = "IPv4 CIDR block for the network (e.g. 10.10.0.0/24)"
type = string
default = "10.10.0.0/24"
validation {
condition = can(cidrnetmask(var.network_cidr))
error_message = "network_cidr must be a valid CIDR notation, e.g. 10.10.0.0/24."
}
}
variable "admin_cidr" {
description = "Source CIDR allowed for SSH (port 22) — use your egress IP, e.g. 203.0.113.10/32. Avoid 0.0.0.0/0."
type = string
validation {
condition = can(cidrnetmask(var.admin_cidr))
error_message = "admin_cidr must be a valid CIDR notation, e.g. 203.0.113.10/32."
}
}
# Compute / VM
variable "vm_name" {
description = "Name of the Docker host VM"
type = string
default = "alb-certbot-docker-host"
}
variable "machine_type" {
description = "STACKIT machine type — list available: stackit server machine-type list --project-id <id>"
type = string
default = "g1.1"
}
variable "availability_zone" {
description = "Availability zone for the VM (e.g. eu01-1, eu01-2, eu01-3)"
type = string
default = "eu01-1"
}
variable "image_id" {
description = "UUID of the boot image (Debian 12 recommended) — list available: stackit image list --all --project-id <id>"
type = string
}
variable "boot_volume_size_gb" {
description = "Root disk size in GB"
type = number
default = 32
validation {
condition = var.boot_volume_size_gb >= 10
error_message = "boot_volume_size_gb must be at least 10 GB."
}
}
variable "keypair_name" {
description = "Name of the SSH key pair to register in STACKIT"
type = string
default = "alb-certbot-workshop-key"
}
variable "ssh_public_key" {
description = "SSH public key string (ssh-ed25519 AAAA... or ssh-rsa AAAA...) — never commit the private key"
type = string
sensitive = true
}
variable "start_nginx_test_container" {
description = "Start an nginx:alpine test container on port 80 via cloud-init (useful for ALB backend health-check validation)"
type = bool
default = true
}
# DNS
variable "dns_zone_name" {
description = "Human-readable label for the DNS zone resource"
type = string
default = "alb-certbot-workshop-zone"
}
variable "dns_name" {
description = "DNS zone apex FQDN, e.g. workshop.example.com — must be a domain you control or can delegate"
type = string
}
variable "dns_contact_email" {
description = "SOA contact email for the DNS zone"
type = string
}
# ALB (Option A)
variable "alb_name" {
description = "Name of the Application Load Balancer"
type = string
default = "alb-certbot-workshop"
}
variable "alb_plan_id" {
description = "ALB service plan — p10 is the smallest available plan"
type = string
default = "p10"
}
variable "alb_target_pool_name" {
description = "Name of the ALB target pool pointing to the Docker host VM"
type = string
default = "docker-host-pool"
}
variable "alb_acl_ranges" {
description = "List of source CIDRs allowed to reach the ALB. Use [\"0.0.0.0/0\"] to allow all traffic."
type = list(string)
default = ["0.0.0.0/0"]
}

View file

@ -0,0 +1,38 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
resource "stackit_resourcemanager_folder" "showcase" {
name = var.folder_name
owner_email = var.owner_email
parent_container_id = var.organization_id
labels = {
environment = "workshop"
managed-by = "terraform"
use-case = "alb-certbot"
}
}
resource "stackit_resourcemanager_project" "showcase" {
name = var.project_name
owner_email = var.owner_email
parent_container_id = stackit_resourcemanager_folder.showcase.id
labels = {
environment = "workshop"
managed-by = "terraform"
folder = var.folder_name
use-case = "alb-certbot"
}
}

View file

@ -0,0 +1,101 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
resource "stackit_network" "workshop" {
project_id = stackit_resourcemanager_project.showcase.project_id
name = var.network_name
ipv4_prefix = var.network_cidr
ipv4_nameservers = ["8.8.8.8", "1.1.1.1"]
routed = true
labels = {
environment = "workshop"
managed-by = "terraform"
}
}
resource "stackit_security_group" "vm" {
project_id = stackit_resourcemanager_project.showcase.project_id
name = "${var.vm_name}-sg"
description = "Security group for the ALB/Certbot workshop Docker host"
stateful = true
labels = {
environment = "workshop"
managed-by = "terraform"
}
}
resource "stackit_security_group_rule" "ssh_ingress" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "ingress"
description = "SSH from admin CIDR"
protocol = { name = "tcp" }
port_range = { min = 22, max = 22 }
ip_range = var.admin_cidr
}
resource "stackit_security_group_rule" "http_ingress" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "ingress"
description = "HTTP for ALB backend and certbot HTTP-01"
protocol = { name = "tcp" }
port_range = { min = 80, max = 80 }
ip_range = "0.0.0.0/0"
}
resource "stackit_security_group_rule" "https_ingress" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "ingress"
description = "HTTPS ingress"
protocol = { name = "tcp" }
port_range = { min = 443, max = 443 }
ip_range = "0.0.0.0/0"
}
resource "stackit_security_group_rule" "egress_tcp" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "egress"
description = "Allow all outbound TCP"
protocol = { name = "tcp" }
ip_range = "0.0.0.0/0"
}
resource "stackit_security_group_rule" "egress_udp" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "egress"
description = "Allow all outbound UDP"
protocol = { name = "udp" }
ip_range = "0.0.0.0/0"
}
resource "stackit_security_group_rule" "egress_icmp" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "egress"
description = "Allow outbound ICMP"
protocol = { name = "icmp" }
ip_range = "0.0.0.0/0"
}

View file

@ -0,0 +1,85 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
locals {
cloud_init_rendered = templatefile("${path.root}/templates/cloud-init.yaml.tpl", {
start_nginx_test_container = var.start_nginx_test_container
})
}
resource "stackit_key_pair" "workshop" {
name = var.keypair_name
public_key = chomp(var.ssh_public_key)
labels = {
environment = "workshop"
managed-by = "terraform"
}
}
resource "stackit_network_interface" "vm" {
project_id = stackit_resourcemanager_project.showcase.project_id
network_id = stackit_network.workshop.network_id
name = "${var.vm_name}-nic"
security_group_ids = [stackit_security_group.vm.security_group_id]
# The ALB injects its target_security_group into this NIC after creation
# ignore_changes prevents Terraform from reverting it on subsequent applies
lifecycle {
ignore_changes = [security_group_ids]
}
}
resource "stackit_public_ip" "vm" {
project_id = stackit_resourcemanager_project.showcase.project_id
network_interface_id = stackit_network_interface.vm.network_interface_id
labels = {
environment = "workshop"
managed-by = "terraform"
}
}
resource "stackit_server" "docker_host" {
project_id = stackit_resourcemanager_project.showcase.project_id
name = var.vm_name
machine_type = var.machine_type
availability_zone = var.availability_zone
keypair_name = stackit_key_pair.workshop.name
user_data = local.cloud_init_rendered
boot_volume = {
source_type = "image"
source_id = var.image_id
size = var.boot_volume_size_gb
}
network_interfaces = [stackit_network_interface.vm.network_interface_id]
agent = {
provisioning_policy = "ALWAYS"
}
labels = {
environment = "workshop"
managed-by = "terraform"
role = "docker-host"
use-case = "alb-certbot"
}
depends_on = [
stackit_network_interface.vm,
stackit_key_pair.workshop,
]
}

View file

@ -0,0 +1,36 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Primary DNS zone used for ACME DNS-01 challenge delegation
# After apply: delegate this zone at your registrar using the output nameservers
resource "stackit_dns_zone" "workshop" {
project_id = stackit_resourcemanager_project.showcase.project_id
name = var.dns_zone_name
dns_name = var.dns_name
contact_email = var.dns_contact_email
type = "primary"
default_ttl = 300
description = "Workshop zone for ALB + Certbot ACME DNS-01 challenge"
}
# A-record pointing the zone apex to the ALB public IP
resource "stackit_dns_record_set" "alb_a" {
project_id = stackit_resourcemanager_project.showcase.project_id
zone_id = stackit_dns_zone.workshop.zone_id
name = var.dns_name
type = "A"
ttl = 300
records = [stackit_public_ip.alb.ip]
}

View file

@ -0,0 +1,141 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
output "folder_id" {
description = "Container ID of the created folder"
value = stackit_resourcemanager_folder.showcase.id
}
output "project_id" {
description = "UUID of the created project — use this for all subsequent STACKIT CLI commands"
value = stackit_resourcemanager_project.showcase.project_id
}
output "network_id" {
description = "UUID of the private network"
value = stackit_network.workshop.network_id
}
output "vm_public_ip" {
description = "Public IPv4 address of the Docker host"
value = stackit_public_ip.vm.ip
}
output "dns_zone_id" {
description = "UUID of the DNS zone"
value = stackit_dns_zone.workshop.id
}
output "dns_primary_nameserver" {
description = "Primary nameserver FQDN for the DNS zone — use for delegation and ACME DNS-01"
value = stackit_dns_zone.workshop.primary_name_server
}
output "ssh_command" {
description = "SSH command to connect to the Docker host (Debian 12 default user)"
value = "ssh debian@${stackit_public_ip.vm.ip}"
}
output "nginx_test_url" {
description = "URL to verify the nginx test container"
value = "http://${stackit_public_ip.vm.ip}"
}
# ALB Outputs
output "alb_public_ip" {
description = "Public IP address of the ALB"
value = stackit_public_ip.alb.ip
}
output "alb_url" {
description = "HTTP URL of the ALB (DNS must be delegated first)"
value = "http://${var.dns_name}"
}
output "alb_name" {
description = "ALB name — use to look up the public IP: stackit load-balancer describe --name <name> --project-id <id>"
value = stackit_application_load_balancer.workshop.name
}
output "alb_private_address" {
description = "ALB private (internal) IP address"
value = stackit_application_load_balancer.workshop.private_address
}
output "alb_target_security_group" {
description = "Security group ID automatically assigned to ALB targets — already injected into VM NIC"
value = stackit_application_load_balancer.workshop.target_security_group
}
output "acme_next_steps" {
description = "Phase 2 instructions: replace the bootstrap cert with a Let's Encrypt cert via stackit-acme-alb on the VM"
value = <<-EOT
PHASE 2 Let's Encrypt certificate via stackit-acme-alb
VM: ${stackit_public_ip.vm.ip} | Domain: ${var.dns_name}
PREREQUISITE: DNS delegation must be active before certbot can run.
Set NS records for ${var.dns_name} to: $(terraform output -raw dns_primary_nameserver)
Verify: dig NS ${var.dns_name}
Step 1: Local upload files to the VM
scp -r stackit-acme-alb debian@${stackit_public_ip.vm.ip}:~/stackit-acme-alb
scp keys/sa-key.json debian@${stackit_public_ip.vm.ip}:~/stackit-acme-alb/sa-key.json
Step 2: Local encode the SA key as Base64 (macOS)
base64 -i keys/sa-key.json | tr -d '\n'
Step 3: SSH to the VM and fill in .env
ssh debian@${stackit_public_ip.vm.ip}
cd ~/stackit-acme-alb
sed -i "s|^PROJECT_ID=.*|PROJECT_ID=${stackit_resourcemanager_project.showcase.project_id}|" .env
sed -i "s|^ALB_NAME=.*|ALB_NAME=${stackit_application_load_balancer.workshop.name}|" .env
sed -i "s|^DOMAIN_WHITELIST=.*|DOMAIN_WHITELIST=${var.dns_name}|" .env
sed -i "s|^DAYS_WARNING=.*|DAYS_WARNING=99999|" .env
sed -i "s|^ALB_SA_KEY_B64=.*|ALB_SA_KEY_B64=$(base64 -w 0 sa-key.json)|" .env
sed -i "s|^DNS_SA_KEY_B64=.*|DNS_SA_KEY_B64=$(base64 -w 0 sa-key.json)|" .env
grep '^ALB_SA_KEY_B64=' .env | cut -d= -f2- | base64 -d >/dev/null && echo "ALB key OK"
grep '^DNS_SA_KEY_B64=' .env | cut -d= -f2- | base64 -d >/dev/null && echo "DNS key OK"
# DAYS_WARNING=99999 forces replacement regardless of bootstrap cert expiry.
# Reset to 30 after the first successful run (Step 6).
Step 4: On VM build the Docker image
docker build -t stackit-alb-cert-updater .
Step 5: On VM run certificate issuance
docker run --rm \
--env-file ~/stackit-acme-alb/.env \
-v ~/stackit-acme-alb/letsencrypt_data:/etc/letsencrypt \
stackit-alb-cert-updater
Step 6: On VM cron job for automatic renewal
# Monthly on the 1st at 03:00 (LE certs valid 90 days, warning at 30)
(crontab -l 2>/dev/null; echo "0 3 1 * * docker run --rm --env-file /home/debian/stackit-acme-alb/.env -v /home/debian/stackit-acme-alb/letsencrypt_data:/etc/letsencrypt stackit-alb-cert-updater >> /home/debian/stackit-acme-alb/renewal.log 2>&1") | crontab -
crontab -l
EOT
}

View file

@ -0,0 +1,89 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Dedicated public IP so Terraform can wire the DNS A-record in a single apply
resource "stackit_public_ip" "alb" {
project_id = stackit_resourcemanager_project.showcase.project_id
labels = {
environment = "workshop"
managed-by = "terraform"
use-case = "alb"
}
}
resource "stackit_application_load_balancer" "workshop" {
project_id = stackit_resourcemanager_project.showcase.project_id
region = var.stackit_region
name = var.alb_name
plan_id = var.alb_plan_id
external_address = stackit_public_ip.alb.ip
networks = [
{
network_id = stackit_network.workshop.network_id
role = "ROLE_LISTENERS_AND_TARGETS"
}
]
listeners = [
{
name = "http"
port = 80
protocol = "PROTOCOL_HTTP"
http = {
hosts = [
{
host = "*"
rules = [{ target_pool = var.alb_target_pool_name }]
}
]
}
}
]
target_pools = [
{
name = var.alb_target_pool_name
target_port = 80
targets = [
{
display_name = "docker-host"
ip = stackit_network_interface.vm.ipv4
}
]
}
]
options = {
private_network_only = false
access_control = {
allowed_source_ranges = var.alb_acl_ranges
}
}
labels = {
environment = "workshop"
managed-by = "terraform"
use-case = "alb-certbot"
}
# certbot patches the HTTPS listener out-of-band via STACKIT API
# ignore_changes prevents Terraform from reverting those updates
lifecycle {
ignore_changes = [listeners, target_pools]
}
depends_on = [stackit_network_interface.vm]
}

View file

@ -0,0 +1,322 @@
# STACKIT ALB + Let's Encrypt Workshop
Infrastructure-as-Code workshop environment demonstrating automated TLS certificate lifecycle on STACKIT — Terraform provisions the ALB with an HTTP listener, certbot issues a Let's Encrypt certificate via ACME DNS-01 and attaches it as an HTTPS listener to the ALB.
---
## Overview
This repository provisions a fully reproducible demo environment on STACKIT using Terraform. It covers the complete path from infrastructure provisioning to automated certificate management.
| Component | Description |
| ---------------------- | --------------------------------------------------------------------------------------------------------- |
| Resource hierarchy | Folder and project under an existing STACKIT organisation |
| Network | Private routed network (`10.10.0.0/24`) with security groups |
| Compute | Debian 12 VM with Docker Engine (provisioned via cloud-init) |
| DNS | Primary zone for ACME DNS-01 challenge validation |
| Load Balancer | Application Load Balancer with HTTP and HTTPS listeners |
| Certificate automation | [vm-alb-certbot-letsencrypt](stackit-acme-alb/README.md) — PowerShell/certbot container running on the VM |
---
## Architecture
```mermaid
sequenceDiagram
participant User
participant TF as Terraform / STACKIT API
participant VM as certbot VM
participant LE as Let's Encrypt
Note over User,TF: Phase 1 — terraform apply
User->>TF: create Network + VM + DNS Zone
TF-->>User: vm_ip · zone ready
User->>TF: create ALB (HTTP listener + target: vm_ip)
TF-->>User: alb_id · alb_public_ip · http ready
Note over User,LE: Phase 2 — certbot on VM
User->>VM: run cert scripts (alb_id)
VM->>LE: request certificate (DNS-01 challenge)
LE-->>VM: .crt · .pem
VM->>TF: store certificate on STACKIT
VM->>TF: attach HTTPS listener + cert to ALB (alb_id)
TF-->>User: ✅ https://your-domain ready
Note over VM: Phase 3+ — monthly cron
VM->>LE: renew certificate (auto, < 30 days remaining)
```
```
STACKIT Organisation
└── Folder: alb-certbot-workshop
└── Project: alb-certbot-dev
├── Network: alb-certbot-net (10.10.0.0/24, routed)
│ └── Security Group: alb-certbot-docker-host-sg
│ ├── Ingress TCP 22 ← admin_cidr only
│ ├── Ingress TCP 80 ← 0.0.0.0/0 (ALB backend traffic)
│ ├── Ingress TCP 443 ← 0.0.0.0/0
│ └── Egress all → 0.0.0.0/0
├── VM: alb-certbot-docker-host (Debian 12)
│ ├── Public IP
│ └── Docker Engine + nginx:alpine (health-check backend)
├── DNS Zone: alb-workshop.stackit.gg (primary)
└── ALB: alb-certbot-workshop
├── Public IP (dedicated, wired to DNS A-record by Terraform)
├── Listener HTTP :80 → VM:80
└── Listener HTTPS :443 → VM:80 (TLS terminated at ALB)
```
The ALB terminates TLS — the backend VM only receives plain HTTP on port 80. This is standard practice: one TLS endpoint, no certificate management on individual backend hosts.
### Certificate lifecycle
```
Phase 1 — terraform apply
ALB provisioned with HTTP listener + target pool (VM private IP).
DNS A-record for the zone apex is created pointing to the ALB public IP.
Phase 2 — certbot (Docker container, runs on the VM)
Runs certbot DNS-01: writes _acme-challenge TXT record via STACKIT DNS API
Let's Encrypt validates the challenge and issues the certificate
Uploads the certificate to STACKIT Certificate Manager
Patches the ALB — adds HTTPS listener with the new certificate ID via API
Phase 3+ — monthly cron job on the VM
Checks remaining certificate validity; renews automatically when < 30 days remain
```
> `lifecycle.ignore_changes = [listeners]` is set on the ALB resource so that `terraform apply` does not revert certificate updates made by vm-alb-certbot-letsencrypt.
---
## Prerequisites
### Required tools
| Tool | Version | Notes |
| -------------------------------------------------------------- | -------- | -------------------------------------------------- |
| [Terraform](https://developer.hashicorp.com/terraform/install) | >= 1.5.0 | Infrastructure provisioning |
| [STACKIT CLI](https://github.com/stackitcloud/stackit-cli) | latest | Image/machine-type lookups, role assignment |
| SSH client | — | VM access |
| Docker | — | Build and run vm-alb-certbot-letsencrypt on the VM |
### STACKIT service account permissions
| Scope | Role | Purpose |
| ------------ | -------------------------------- | ---------------------------- |
| Organisation | `resourcemanager.folders.admin` | Create folder |
| Folder | `resourcemanager.projects.admin` | Create project |
| Project | `compute.admin` | VM, network, security groups |
| Project | `dns.admin` | DNS zone and records |
| Project | `load-balancer.admin` | ALB and Certificate Manager |
> **Note:** Folder and project creation via Terraform requires elevated permissions at the organisation level. As an alternative, create the folder and project manually in the STACKIT Portal and import them into Terraform state.
### Create and configure the service account
```bash
# Create service account
stackit iam service-account create \
--project-id <project-id> \
--name "tf-workshop-sa"
# Generate a key — stored in keys/ (gitignored)
mkdir -p keys
stackit iam service-account key create \
--project-id <project-id> \
--service-account-email <sa-email> \
--output-format json > keys/sa-key.json
# Assign load-balancer.admin at project level (required for ALB API)
stackit project member add <sa-email> \
--project-id <project-id> \
--role load-balancer.admin
```
---
## Deployment
### 1. Look up required image and machine-type IDs
```bash
# Debian 12 image UUID
stackit image list --all --project-id <project-id>
# Available machine types
stackit server machine-type list --project-id <project-id>
```
### 2. Configure variables
```bash
cp examples/terraform.tfvars.example terraform.tfvars
```
Key values to set:
```hcl
organization_id = "<STACKIT Portal Organisation Settings>"
owner_email = "your.name@example.com"
image_id = "<Debian 12 UUID from step above>"
admin_cidr = "<your-public-ip>/32" # curl -s https://ifconfig.schwarz
ssh_public_key = "ssh-ed25519 AAAA..."
dns_name = "alb-workshop.stackit.gg"
```
### 3. Configure the remote state backend
```bash
cp examples/backend.conf.example backend.conf
# Fill in: bucket, key, access_key, secret_key (STACKIT Object Storage)
```
### 4. Deploy
```bash
terraform init -backend-config=backend.conf
terraform plan
terraform apply
```
---
## Validation
```bash
# Show all outputs — includes pre-filled Phase 2 instructions
terraform output
# SSH to the VM
ssh debian@$(terraform output -raw vm_public_ip)
# Verify Docker and the nginx test container
curl http://$(terraform output -raw vm_public_ip)
# Inspect DNS zone nameservers
terraform output dns_primary_nameserver
```
### DNS delegation
After `terraform apply`, delegate the DNS zone at your registrar by pointing its NS records to the value returned by:
```bash
terraform output dns_primary_nameserver
```
Verify propagation:
```bash
dig NS alb-workshop.stackit.gg
```
---
## Phase 2 — Let's Encrypt certificate
Run `terraform output acme_next_steps` for the complete step-by-step guide with all values pre-filled from your deployment.
**Quick summary:**
```bash
# 1. Upload files to the VM
scp -r vm-alb-certbot-letsencrypt debian@<VM_IP>:~/vm-alb-certbot-letsencrypt
scp keys/sa-key.json debian@<VM_IP>:~/vm-alb-certbot-letsencrypt/sa-key.json
# 2. SSH to the VM and patch .env with deployment-specific values
ssh debian@<VM_IP>
cd ~/vm-alb-certbot-letsencrypt
# (see: terraform output acme_next_steps → Step 3)
# 3. Build the image
docker build -t stackit-alb-cert-updater .
# 4. Staging run first — no Let's Encrypt rate-limit risk
docker run --rm \
--env-file ~/vm-alb-certbot-letsencrypt/.env \
-v ~/vm-alb-certbot-letsencrypt/letsencrypt_data:/etc/letsencrypt \
stackit-alb-cert-updater
# 5. Switch to production ACME server and run again
# 6. Set up the monthly renewal cron job
# (see: terraform output acme_next_steps → Step 7)
```
See [vm-alb-certbot-letsencrypt/readme.md](vm-alb-certbot-letsencrypt/readme.md) for full documentation on the certificate automation container.
---
## File Structure
```
.
├── 00-backend.tf # S3-compatible remote state (STACKIT Object Storage)
├── 00-provider.tf # Provider declarations (stackit, random)
├── 01-variables.tf # All input variables with descriptions and defaults
├── 02-resource-hierarchy.tf # Folder and project
├── 03-network.tf # VPC, security group, ingress/egress rules
├── 04-compute.tf # SSH key, NIC, public IP, Debian 12 VM
├── 05-dns.tf # Primary DNS zone + ALB A-record
├── 06-outputs.tf # All outputs including Phase 2 instructions
├── 07-alb.tf # Public IP + Application Load Balancer (HTTP only initially)
├── docs/
│ └── architecture.md # Deployment sequence diagram + certificate lifecycle
├── templates/
│ └── cloud-init.yaml.tpl # Docker Engine install + nginx test container
├── examples/
│ ├── terraform.tfvars.example # Variable template — copy to terraform.tfvars
│ └── backend.conf.example # Backend template — copy to backend.conf
├── vm-alb-certbot-letsencrypt/ # Phase 2: certificate automation (Docker / PowerShell)
│ ├── Dockerfile
│ ├── .env.default # Configuration template
│ ├── .env # Active config — gitignored, never commit
│ └── app/
│ ├── Main_CertRenew_CLI.ps1
│ └── lib/
└── keys/ # Service account key JSON — gitignored
```
---
## Security
| File / Directory | Git status | Contains |
| ---------------------------------------------- | ---------- | --------------------------------------- |
| `terraform.tfvars` | gitignored | SSH public key, sensitive configuration |
| `backend.conf` | gitignored | Object Storage access and secret keys |
| `keys/` | gitignored | Service account JSON key |
| `vm-alb-certbot-letsencrypt/.env` | gitignored | Base64-encoded SA key, ACME config |
| `vm-alb-certbot-letsencrypt/letsencrypt_data/` | gitignored | Private keys issued by Let's Encrypt |
- SSH access is restricted to `admin_cidr` — do not use `0.0.0.0/0`
- Never commit private SSH keys or service account keys to the repository
---
## Cleanup
```bash
terraform destroy
```
> **Note:** After `terraform destroy`, STACKIT projects remain in "Pending Deletion" for up to 7 days. The parent folder cannot be deleted until all child projects are fully removed. This is expected STACKIT platform behaviour.
---
## References
- [Full architecture details](docs/architecture.md)
- [STACKIT Terraform Provider](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs)
- [STACKIT CLI](https://github.com/stackitcloud/stackit-cli)
- [STACKIT Developer Documentation](https://docs.stackit.cloud)
- [certbot-dns-stackit](https://pypi.org/project/certbot-dns-stackit/)

View file

@ -0,0 +1,135 @@
# Architecture: vm-alb-certbot-letsencrypt
## Deployment Sequence
```mermaid
sequenceDiagram
participant User
participant TF as Terraform / STACKIT API
participant VM as certbot VM
participant LE as Let's Encrypt
Note over User,TF: Phase 1 — terraform apply
User->>TF: create Network + VM + DNS Zone
TF-->>User: vm_ip · zone ready
User->>TF: create ALB (HTTP listener + target: vm_ip)
TF-->>User: alb_id · alb_public_ip · http ready
Note over User,LE: Phase 2 — certbot on VM
User->>VM: run cert scripts (alb_id)
VM->>LE: request certificate (DNS-01 challenge)
LE-->>VM: .crt · .pem
VM->>TF: store certificate on STACKIT
VM->>TF: attach HTTPS listener + cert to ALB (alb_id)
TF-->>User: ✅ https://your-domain ready
Note over VM: Phase 3+ — monthly cron
VM->>LE: renew certificate (auto, < 30 days remaining)
```
---
# STACKIT ALB + Let's Encrypt (ACME)
## Traffic Flow
```
Client
│ DNS lookup: alb-workshop.stackit.gg
STACKIT DNS
│ resolves to ALB public IP (Terraform A-record)
│ HTTPS :443
STACKIT ALB (L7, TLS termination)
│ certificate: Let's Encrypt (managed by vm-alb-certbot-letsencrypt)
│ HTTP routing to target pool
│ HTTP :80
STACKIT VM (Debian 12, Docker Engine)
Container: nginx:alpine (port 80, health-check backend)
```
The ALB terminates TLS. The VM only receives plain HTTP on port 80 — no
certificate management on the backend.
---
## Three-Phase Deployment
### Phase 1 — `terraform apply`
Terraform provisions the complete infrastructure in a single apply:
| Resource | File |
| ----------------------------------------------------------------- | -------------------------- |
| STACKIT Folder + Project | `02-resource-hierarchy.tf` |
| Network + Security Group | `03-network.tf` |
| VM (Debian 12, Docker Engine) | `04-compute.tf` |
| DNS Zone + A-record → ALB IP | `05-dns.tf` |
| Application Load Balancer (HTTP listener + target: VM private IP) | `07-alb.tf` |
The ALB is created with an HTTP listener and target pool pointing to the VM. The HTTPS listener is added by certbot in Phase 2. `lifecycle { ignore_changes = [listeners, target_pools] }` prevents Terraform from reverting out-of-band changes.
### Phase 2 — certbot (Docker container on the VM)
```
vm-alb-certbot-letsencrypt container
├── 1. Run certbot DNS-01
│ └── write _acme-challenge TXT via STACKIT DNS API
├── 2. Let's Encrypt validates + issues certificate
├── 3. Upload certificate to STACKIT Certificate Manager
└── 4. Patch ALB HTTPS listener with new certificate ID
```
`lifecycle { ignore_changes = [listeners] }` is set on the ALB resource so
that subsequent `terraform apply` runs do not revert the certificate update.
### Phase 3 — `terraform apply`
Wire up target pool (VM private IP) to the ALB so traffic reaches the backend.
### Phase 4+ — monthly renewal
A cron job on the VM re-runs the container. It renews automatically when
fewer than 30 days of validity remain.
---
## Certificate Lifecycle (DNS-01)
```
vm-alb-certbot-letsencrypt STACKIT DNS API Let's Encrypt STACKIT Cert Mgr ALB
│ │ │ │ │
│── certbot DNS-01 ─►│ │ │ │
│ _acme-challenge │ │ │ │
│ TXT record │ │ │ │
│ │◄── verify TXT ────│ │ │
│◄────────────── certificate issued ─────│ │ │
│── delete TXT ─────►│ │ │ │
│── upload cert ─────────────────────────────────────────►│ │
│── PATCH listener ──────────────────────────────────────────────────────►│
```
---
## Component Responsibility
| Component | Provisioned by | Purpose |
| ------------------------- | ---------------------- | ----------------------------------------------- |
| STACKIT Folder + Project | Terraform | Resource boundary |
| Network + Security Group | Terraform | Private network, SSH + HTTP rules |
| VM (Debian 12) | Terraform + cloud-init | Docker host |
| DNS Zone + A-record | Terraform | Zone apex → ALB IP |
| Application Load Balancer | Terraform | HTTP listener initially, HTTPS added by certbot |
| Let's Encrypt certificate | certbot (Docker on VM) | First issuance + monthly renewal |

View file

@ -0,0 +1,15 @@
bucket = ""
key = ""
region = "eu01"
endpoints = {
s3 = "https://object.storage.eu01.onstackit.cloud"
}
access_key = ""
secret_key = ""
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_s3_checksum = true

View file

@ -0,0 +1,51 @@
# Copy this file to terraform.tfvars and fill in your values.
# terraform.tfvars is gitignored — never commit real values.
# ─── Provider ─────────────────────────────────────────────────────────────────
stackit_region = "eu01"
stackit_service_account_key_path = "keys/sa-key.json"
# ─── Resource Hierarchy ───────────────────────────────────────────────────────
# Find your Organisation ID in the STACKIT Portal → Organisation → Settings
organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
owner_email = "your.name@example.com"
folder_name = "alb-certbot-workshop"
project_name = "alb-certbot-dev"
# ─── Network ──────────────────────────────────────────────────────────────────
network_name = "alb-certbot-net"
network_cidr = "10.10.0.0/24"
# Restrict SSH to your IP — never use 0.0.0.0/0 in production
# Find your IP: curl -s https://ifconfig.schwarz
admin_cidr = "203.0.113.10/32"
# ─── Compute / VM ─────────────────────────────────────────────────────────────
vm_name = "alb-certbot-docker-host"
availability_zone = "eu01-1"
# List available machine types:
# stackit server machine-type list --project-id <id>
machine_type = "g1.1"
# List available images (Debian 12 recommended):
# stackit image list --all --project-id <id>
image_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume_size_gb = 32
keypair_name = "alb-certbot-workshop-key"
ssh_public_key = "ssh-ed25519 AAAA... your-key-comment"
start_nginx_test_container = true
# ─── DNS ──────────────────────────────────────────────────────────────────────
dns_zone_name = "alb-certbot-workshop-zone"
dns_name = "workshop.example.com"
dns_contact_email = "your.name@example.com"

View file

@ -0,0 +1,20 @@
PROJECT_ID=
REGION_ID=eu01
ALB_NAME=
DOMAIN_WHITELIST=
DAYS_WARNING=30
# Path where Certbot saves the certificates (Default: /etc/letsencrypt/live)
CERTBOT_LIVE_PATH=/etc/letsencrypt/live
# Mode Switch: set to 'true' for Delegation Mode (Mode 2), 'false' for Direct Mode (Mode 1)
USE_CHALLENGE_DELEGATION=true
VERIFY_ZONE_FQDN=
# Service Account Keys (Base64 encoded)
ALB_SA_KEY_B64=
DNS_SA_KEY_B64=
# Default: Let's Encrypt staging (safe for testing — no rate limits).
# Switch to production before deploying for real: https://acme-v02.api.letsencrypt.org/directory
ACME_SERVER=https://acme-staging-v02.api.letsencrypt.org/directory

View file

@ -0,0 +1,64 @@
### macOS
# Finder metadata
.DS_Store
# Thumbnails
._*
# Custom folder icons
Icon
# Volume root files
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
### Windows
# Windows thumbnail cache files
Thumbs.db
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows shortcuts
*.lnk
### Linux
# Backup files
*~
# Temporary files from deleted open files
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder
.Trash-*
# NFS temporary files
.nfs*
### VS Code
# VSCode settings (keep shared configuration)
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Custom
.env

View file

@ -0,0 +1,71 @@
# -----------------------------------------------------------------------------
# STACKIT ALB Certificate Auto-Updater
# Description: Containerized environment to automate Let's Encrypt DNS-01
# challenges and update STACKIT Application Load Balancers.
#
# DISCLAIMER: This implementation is provided as a baseline/Proof of Concept.
# Depending on your organization's specific requirements, security
# policies, and special use cases, you may need to adapt and
# extend the underlying scripts (e.g., adding custom alerting,
# advanced error handling, or specific secret management integrations).
# -----------------------------------------------------------------------------
FROM mcr.microsoft.com/powershell:7.4-debian-12
# Standard OCI Metadata Labels
LABEL org.opencontainers.image.title="STACKIT ALB Cert Updater"
LABEL org.opencontainers.image.description="Automates ACME certificate renewal for STACKIT Application Load Balancers"
LABEL org.opencontainers.image.authors="Schwarz Digits Cloud GmbH & Co. KG"
# Disable interactive prompts during apt package installation
ENV DEBIAN_FRONTEND=noninteractive
# ==========================================
# PHASE 1: SYSTEM BASE & SECURITY
# ==========================================
# Install core utilities and update existing packages to fix CVEs
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
curl \
gnupg \
python3 \
python3-venv && \
rm -rf /var/lib/apt/lists/*
# ==========================================
# PHASE 2: STACKIT CLI
# ==========================================
# Add official STACKIT repository and install the CLI
RUN curl -sL https://packages.stackit.cloud/keys/key.gpg | gpg --dearmor -o /usr/share/keyrings/stackit.gpg && \
echo "deb [signed-by=/usr/share/keyrings/stackit.gpg] https://packages.stackit.cloud/apt/cli stackit main" > /etc/apt/sources.list.d/stackit.list && \
apt-get update && \
apt-get install -y --no-install-recommends stackit && \
rm -rf /var/lib/apt/lists/*
# ==========================================
# PHASE 3: CERTBOT & ACME DNS PLUGIN
# ==========================================
# Set up a PEP-668 compliant Virtual Environment
ENV VIRTUAL_ENV=/opt/certbot-venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN python3 -m venv $VIRTUAL_ENV
# Upgrade the venv's pip and install Certbot with the STACKIT plugin
RUN pip3 install --no-cache-dir --upgrade pip setuptools && \
pip3 install --no-cache-dir certbot certbot-dns-stackit
# ==========================================
# PHASE 4: APPLICATION WORKSPACE
# ==========================================
WORKDIR /app
# Copy the application scripts into the container
COPY app/ /app/
# Define the default execution command
ENTRYPOINT ["pwsh", "-File", "/app/Main_CertRenew_CLI.ps1"]

View file

@ -0,0 +1,201 @@
# STACKIT ALB Certificate Auto-Updater
> **DISCLAIMER:** This repository provides a Proof of Concept (PoC) and baseline implementation. It was successfully developed and tested on a Windows 10 environment using Docker Desktop. Depending on your organization's specific requirements, security policies, and special use cases, you must adapt and extend the underlying scripts (e.g., adding custom alerting, advanced error handling, or specific secret management integrations) before deploying this to a production environment.
## Overview
Securing web traffic via TLS/SSL is a standard requirement for applications exposed through a STACKIT Application Load Balancer (ALB). When using Let's Encrypt certificates, an automated renewal process is highly recommended due to their short 90-day validity period.
This solution automates the provisioning, uploading, and updating of ACME certificates without requiring inbound HTTP traffic to a validation endpoint. It leverages the **DNS-01 challenge** via the STACKIT DNS API, uploads the resulting certificates to the STACKIT Certificate Manager, and patches the Application Load Balancer to use the new certificates seamlessly.
---
## How it Works
This automation supports two different workflows for validating domain ownership via DNS, depending on where the target domain's DNS zone is hosted.
### Mode 1: Direct DNS Update (Normal Challenge)
**Use Case:** The target domain (e.g., `web.yourdomain.com`) is hosted directly within a STACKIT DNS Zone that the provided Service Account has access to.
In this mode, the script uses the official STACKIT Certbot DNS plugin to create the `_acme-challenge` TXT record directly in the domain's primary DNS zone.
**High-Level Architecture:**<br/>
![Simple Normal DNS Challenge](./docs/images/mode1_high_level.png)
<!--- https://www.plantuml.com/plantuml/png/LP5BRjim48RtEiKFRv8iBA1lm8iYZkr5KA4eQY4eicEfaKEO8jNXi83kqgDqbukLqoH2jDA3typ_u7KImv87NrLZUN7MZIO8LjiiSJ3YOi1nIaB32YP1_owvXkGYuH0iJwCeW-Qm9DxMEBRRtPVR5YsRldSM-wz7tCT0ogCjNyk95tpRt2YXpwgwm9iQzrImtfsD91DwDYUgr1BMduliHedlz2jJ4hJD4Rj2eu-CbHbTpJcaEeQIrvwTrDR7tAsgve1rcTOj6nIhtpZ-TXrXzs2yft0YjF5CeUZhgHg_CzvrZigw4oxtZkaiLxCB3RF1knex9YE4KjmOsV24zaTRpDd87ReoPCLFS66kHEjGaJBw-ESdySYFVtkLw3BR1ongLjEprliQTyIkVwrGwH7Mpwryq5OaStYAWil_2PxkDcLhAQK--wkYhiksEIKXxrKxXmvx6dsS1WgoxEfZZTkKCirDknB3o7mXUciRVAgV5xn6K80ccWutnfjWYQjh2bqU_3y0 --->
**Detailed Technical Sequence:**<br/>
![Detailed Normal DNS Challenge](./docs/images/mode1_detail_level.png)
## <!--- https://www.plantuml.com/plantuml/png/PPFHRzCm4CRVyrUS-i0sYKR0On_Gkcv85HKgD0p42xhQryogOnlxtAt_FRPBcsIyH4dyk_i-VsVV1aRFiTCLHhOcTbloLUNIFoMKGyCmcQU53bbP0nlXbUC9OFZtEYOtpNpnUTd0V7K7y_MoSEbz32t8yzOoN9_fjOwjCZU5Nho2FzHmnXgFkvqISFzb0x-ieS8twMjSiIA-2l1WX3ywlhXOFLJL5SotEuyjWQJadv5Zg4xRWEd7R7G6duZ54mXA3PCMCa4e7EoiXmawLVjeGcrD-YtsYckXRPIJAkzucfgSsitW6t7q1kZ5AN-AJY9Jg2gRn9OxM0mKL3XnohGGh3KL043lQv5iBOrYbLbFXfvHW_DMK0WPJK32qWwpwfz8WI4nmpraAgqNWxdRsjk3afIJdBvx3-89jIRJ4h3Tqqc-F8nb0diWdwhUbgXdS9xUU7Z0kATGs5BP-pgNUShscvyDU1BMIvZyHj7Hz29Uvt3hUW9I9OBriUzHJFz0nMKAhRPRrDbyJi5XM_8jzAiu6g_QbZmSK2jveT9Ix-jh97ySsXgg5mQcfTjbf2KZk7wyz8GGHPx5BkgRKlKwxcK8aG1DQcwVZROeou2QrMPu3FwFJ1EQw6PmJvgOWAbprkF8xd3Ne_FtyA9uO4mVuJfh1GXu80biGQgm7_YzWfNTeoPufAIn9SNHj12DJF9EssD7XeS7StJMKK8MIRDKfdRKmHjmNXtsTCnVVLb_pxwxSmx335BT6lC5NEN5EzHe1FcAZAnkqoDs-Yy0 --->
### Mode 2: ACME Challenge Delegation via CNAME
**Use Case:** Managing Let's Encrypt renewals for external domains (e.g., domains owned by end-customers). You do not have (and should not require) API credentials to directly modify their production DNS zones.
To solve this, this automation leverages **ACME Challenge Delegation via CNAME**. This allows Let's Encrypt to validate domain ownership by redirecting the challenge request to a central, isolated "Verification Zone" hosted on STACKIT. The customer only needs to create a one-time CNAME record pointing to this central zone.
**High-Level Architecture:**<br/>
![Simple Delegated DNS Challenge](./docs/images/mode2_high_level.png)
<!--- https://www.plantuml.com/plantuml/png/bP9VRzCm5CNV_IcEyi9M4KlyHtsOrcLzc0W3qQX2l23Zt88rnmxsJKlvzDXXBGiWq9xo9-VUSyxFEO-i0W-T5Q9FDqQRNZb6iHZOTugDTrZj8rDNG4NajrDTJVmWOkbqsFSyQkhbLF58eXfYsaO1bzShVF2EHc6LdgRI9nAhPb6zkcgmfc0s--d0-e-1WtfHNsH5lWhXF1FybiBeyF3d632RlN5Kika8A-AXdyzc0h-YzTuuZYW-AjrHgRyeQybz9hdRxQI-5Qc-jqQFL6IRy1XN280zYd-hfBAVoktOgAHlgkijoSHTtnYMttCxGQv5pYr4HepoB66S8w5xHry7YeocNg6YmDZuWE9_eUebohGZxvhZiSJRuBUc9LI7McZy3RcZMKOqHy9V1L9COEfw7rJOetdVf0pQXnR6_Nuw-MJ4Q7Q6QbpnL1w-tHjaNe3ojlcptBmrWJJZfBfUl5wUWlrV_PFziS7pFARQkwbFrigQThH27BIc63U3FHLJHYyahc8SwCZPR78-EX9AVzOSBoKkgRT-VrUISzK-cLuegvoc8Cx9jKDdVm00 --->
**Detailed Technical Sequence:**<br/>
![Detailed Delegated DNS Challenge](./docs/images/mode2_detail_level.png)
## <!--- https://www.plantuml.com/plantuml/png/hLLDZzem4BtdLumuj2nQejtsnc4b2AasQlQZ9KkhNYgJ3C72sAxjmD9VNzi14bZKRTLUK0ZFyzwyUMD8PGIlLZDKGI29CZIdad03dQ7zOKKC5HmckQBIT83etUCnSf8Deuz91iDq1CF7nEFiyp4eGcLcmi0Rk1LYgJqeEi5SXn1LsjAYWl7z1Bu9ZZNGVZk6lWzXJpj5HUVRumFJ5mTID6yrn3nNMsaECjCu2BWm269jEHtDPNqMeDURJ5AO3jDaFCoIXtkOnDdJ8tGVEFODNU75eDUpi8DWAIWt6ZhVIRx2VhuWZ24lCTniDc6-0n4f6SszgM5XtQMyOqiO0PtVeqB2PlsrxxEFpsGb6ORQa7n9JVZJzHmWBy0r4WJrXA1_usOmW0zeyWNardqwTqKHx45JQPWBzHfj9t0ZxTjITT41V4PJAGxuBAcgir7OaZe8XALCvX2rUAYkSPINe8NWjbE0HbranIMS6kgm2Zbk20EfH8vQUsWqSh0qTgm_AjG6BARTYKUbiHVegZqgiQO5kYJqhwxXC09hy19hUl_H6lYe7lTWhyk6Vk432eOiKNjKIqoDZHGwpTdNpEhEXIg0c7-VOCFYUlb2gF4ZAfcOMMDSkgqvafIrxLqCo_0IhjzTwOkciyQoJD6oj1sk2QE5FtAk_pJUzt-y7dF1cDXeC0k4w7vu5pipAJyOVkDNPG2VAbHRRuHFqzz77tmXFy5wq3vUDQLVA1SUknylTxsbPNfEo_yOn_ICjfDKGNTwS1XqbRlqpYlMcaahe8dM5GAngrg83MU253xIMJffByuV1JLYI7WbNyzgZHl061dQQWPNKYYoSwgExt5-MjiUFziGDasnBNITJWsQhB5mSoceNbwS4xmlSMkxTIyEmXkjFbrpob2Vt0pDPMCDShm-VA74bVtta8gkbxXrtnhUapllIHwyRLz8hHorNzyHJigTTIU57ecxF5DNR4GOuRbJBQ3I-A9uCdPty2y0 --->
## Prerequisites
To run this automation, you need the following STACKIT resources and credentials:
1. **A STACKIT Project** with an active **Application Load Balancer** (ALB).
2. **A Target DNS Zone OR a Verification DNS Zone:** Depending on the mode you choose.
3. **DNS Validator Service Account:** A STACKIT Service Account strictly scoped to manage TXT records in the relevant DNS Zone.
4. **ALB Manager Service Account:** A STACKIT Service Account with permissions for the STACKIT Certificate Manager (`POST`/`GET`/`DELETE`) and the Application Load Balancer API (`GET`/`PUT`).
_Note: You can pass these credentials either as local JSON files or as Base64 encoded environment variables (recommended for Docker/CI)._
## Repository Structure
```text
.
├── Dockerfile # Debian-based image definition with Certbot & STACKIT CLI
├── README.md # This documentation
├── keys/ # (Local execution only) Directory for Service Account JSON keys
└── app/
├── Main_CertRenew_CLI.ps1 # The main orchestration script
└── lib/
├── Get-StackitAlbCertStatus_CLI.ps1 # Helper: Evaluates ALB cert expiration
├── StackitHelper_CLI.ps1 # Helper: Handles API uploads & ALB patching
├── Stackit-DnsHook.ps1 # Hook: Creates TXT records in Verification Zone (Mode 2)
└── Stackit-CleanupHook.ps1 # Hook: Deletes TXT records after validation (Mode 2)
```
## Execution Methods
The script is highly flexible. You can run it via Docker (using Base64 environment variables) or locally (using JSON files).
### Option A: Running via Docker (Recommended for CI/CD)
For Docker and CI/CD pipelines, passing JSON files via volume mounts is cumbersome and a security risk. Instead, this automation supports passing the Service Account credentials directly as **Base64 encoded strings** via environment variables. The script will safely decode them in memory during execution.
**1. Convert your `.json` Service Account keys to Base64 (single line):**
- _Linux/macOS:_ `base64 -w 0 alb-manager-sa.json`
- _PowerShell:_ `[convert]::ToBase64String([IO.File]::ReadAllBytes("alb-manager-sa.json"))`
**2. Create an `.env` file** in the root of the repository:
```env
PROJECT_ID=1b1a69ac-1482-4b44-9165-3470c0b8fb79
REGION_ID=eu01
ALB_NAME=your-alb-name
DOMAIN_WHITELIST=web.yourdomain.com,api.yourdomain.com
DAYS_WARNING=30
# Path where Certbot saves the certificates (Default: /etc/letsencrypt/live)
CERTBOT_LIVE_PATH=/etc/letsencrypt/live
# Mode Switch: set to 'true' for Delegation Mode (Mode 2), 'false' for Direct Mode (Mode 1)
USE_CHALLENGE_DELEGATION=true
VERIFY_ZONE_FQDN=alb-verify-poc-ldb-acme.stackit.zone
# Service Account Keys (Base64 encoded)
ALB_SA_KEY_B64=<BASE64_ENCODED_SA_KEY> # gitleaks:allow
DNS_SA_KEY_B64=<BASE64_ENCODED_SA_KEY> # gitleaks:allow
# Use Staging for testing to avoid Let's Encrypt rate limits!
ACME_SERVER=https://acme-staging-v02.api.letsencrypt.org/directory
# For Production, comment out the line above and use:
# ACME_SERVER=https://acme-v02.api.letsencrypt.org/directory
```
**3. Build and Run the Docker Container:**
You only need to mount the volume for Certbot to persist the certificates. You do **not** need to mount the `keys` folder!
Linux:
```bash
docker build -t stackit-alb-cert-updater .
docker run --rm -it \
--env-file .env \
-v "$(pwd)/letsencrypt_data:/etc/letsencrypt" \
stackit-alb-cert-updater
```
Windows:
```powershell
docker build -t stackit-alb-cert-updater .
docker run --rm -it `
--env-file .env `
-v "${PWD}\letsencrypt_data:/etc/letsencrypt" `
stackit-alb-cert-updater
```
---
### Option B: Running Locally (PowerShell)
If you are running the script locally (e.g., for testing on your workstation), you can simply pass the direct file paths to your `.json` Service Account keys.
Linux:
```powershell
./app/Main_CertRenew_CLI.ps1 `
-ProjectId "1b1a69ac-1482-4b44-9165-3470c0b8fb79" `
-RegionId "eu01" `
-AlbName "your-alb-name" `
-DomainWhitelist "www.customerapplication.domain", "www2.customerapplication.domain" `
-UseChallengeDelegation $true `
-VerifyZoneFQDN "alb-verify-example.stackit.zone" `
-CertbotLive "/etc/letsencrypt/live" `
-DNS_SAKeyPath "./keys/dns-validator-sa.json" `
-ALB_SAKeyPath "./keys/alb-manager-sa.json" `
-AcmeServer "https://acme-staging-v02.api.letsencrypt.org/directory"
```
Windows:
```powershell
.\app\Main_CertRenew_CLI.ps1 `
-ProjectId "1b1a69ac-1482-4b44-9165-3470c0b8fb79" `
-RegionId "eu01" `
-AlbName "your-alb-name" `
-DomainWhitelist "www.customerapplication.domain", "www2.customerapplication.domain" `
-UseChallengeDelegation $true `
-VerifyZoneFQDN "alb-verify-example.stackit.zone" `
-CertbotLive "C:\Certbot\live" `
-DNS_SAKeyPath "./keys/dns-validator-sa.json" `
-ALB_SAKeyPath "./keys/alb-manager-sa.json" `
-AcmeServer "https://acme-staging-v02.api.letsencrypt.org/directory"
```
## 💡 Important Note on Let's Encrypt Caching (For Testing)
When testing the renewal process, please be aware of Let's Encrypt's **Authorization Caching**. Once a domain has been successfully validated via the DNS-01 challenge, Let's Encrypt caches this successful validation for **30 days**.
If you trigger a forced renewal for the exact same domain within this window, Let's Encrypt will skip the DNS challenge entirely. Consequently, Certbot will **not** execute the DNS plugin or hook scripts (creation/deletion of the TXT record). To forcefully test the entire workflow including the DNS hooks, you must either test with a new, unvalidated subdomain or ensure you clear your local Certbot account data while using the Staging Environment.
## Limitations & Out of Scope (PoC Boundaries)
As this is a baseline Proof of Concept intended for integration into existing CI/CD environments, the following features are deliberately **not implemented** and should be managed by the surrounding automation platform:
- **CI/CD Pipeline Configurations:** This repository provides the core automation logic via Docker and PowerShell but does _not_ include ready-to-use pipeline definitions (e.g., `.gitlab-ci.yml`, GitHub Actions workflows, or Kubernetes `CronJob` manifests). The consumer must wrap this container into their own scheduling execution engine.
- **Enterprise Secret Management:** While the script supports Base64 encoded environment variables for secure pipeline injection, native API integration with external secret management solutions (e.g., HashiCorp Vault, Azure Key Vault, AWS Secrets Manager) is out of scope.
- **Orphaned Certificate Cleanup:** The script uploads new certificates but does _not_ auto-delete expired or detached certificates from the STACKIT Certificate Manager. This design choice prevents the accidental deletion of certificates that might still be bound to other resources. Housekeeping of orphaned certificates remains a separate operational process.
- **Multi-Domain (SAN) & Wildcard Certificates:** This workflow is heavily optimized for a 1:1 certificate-to-domain mapping. Grouping multiple domains under a single Subject Alternative Name (SAN) certificate or handling `*.domain.com` wildcards requires manual extensions to the underlying Certbot arguments and ALB patching logic.
- **Advanced Alerting & Notifications:** The script outputs clear status and error messages to standard `stdout`/`stderr` streams. It does not natively implement webhooks for chat platforms (e.g., MS Teams, Slack) or Email. It relies on the overarching CI/CD platform to scrape logs and trigger alerts based on the exit code (0 for success, >0 for errors).
- **Automated Rollbacks:** If the patching of the Application Load Balancer fails _after_ a new certificate has been successfully uploaded to STACKIT, the script will throw an error but will not automatically remove the newly uploaded certificate.
- **Concurrency & State Locking:** There is no native distributed state-locking mechanism. Running multiple instances of this script simultaneously against the same ALB configuration could lead to race conditions during the patching phase.
## Next Steps / Automation
For continuous operation, you should schedule this script/container to run regularly (e.g., daily). You can achieve this via:
- A standard Linux `cron` job or in windows (using the task scheduler) on a dedicated server.
- A CI/CD Pipeline schedule (e.g., GitLab CI, GitHub Actions).
- A Kubernetes `CronJob` within STACKIT Kubernetes Engine (SKE), securely mounting the service account keys as Kubernetes Secrets.

View file

@ -0,0 +1,263 @@
<#
.SYNOPSIS
STACKIT ALB Certificate Auto-Renewal Orchestrator (PoC)
.DESCRIPTION
This script automates the renewal of Let's Encrypt certificates for a STACKIT Application Load Balancer.
It identifies expiring certificates, triggers Certbot to perform a DNS-01 challenge, uploads the
renewed certificates to the STACKIT Certificate Manager, and patches the ALB configuration.
This is intended as a Proof of Concept (PoC) baseline for CI/CD integration.
It supports passing Service Account keys as Base64 environment variables to avoid persisting secrets on disk.
#>
param (
# Core Infrastructure
[string] $ProjectId = $env:PROJECT_ID,
[string] $RegionId = $(if ($env:REGION_ID) { $env:REGION_ID } else { "eu01" }),
[string] $AlbName = $env:ALB_NAME,
# Optional filtering: Only renew domains listed here
[string[]] $DomainWhitelist = @(),
# Paths & Credentials
[string] $CertbotLive = $(if ($env:CERTBOT_LIVE_PATH) { $env:CERTBOT_LIVE_PATH } else { "/etc/letsencrypt/live" }),
[string] $DNS_SAKeyPath = $(if ($env:DNS_SA_KEY_PATH) { $env:DNS_SA_KEY_PATH } else { "$PSScriptRoot/keys/dns-validator-sa.json" }),
[string] $ALB_SAKeyPath = $(if ($env:ALB_SA_KEY_PATH) { $env:ALB_SA_KEY_PATH } else { "$PSScriptRoot/keys/alb-manager-sa.json" }),
# Execution Modifiers
[bool] $SkipCertbot = $(if ($env:SKIP_CERTBOT -match '^(true|1|yes)$') { $true } else { $false }),
[int] $DaysWarning = $(if ($env:DAYS_WARNING) { [int]$env:DAYS_WARNING } else { 30 }),
# API Endpoints
[string] $AlbBaseUrl = $(if ($env:ALB_BASE_URL) { $env:ALB_BASE_URL } else { "https://alb.api.stackit.cloud/v2" }),
[string] $CertBaseUrl = $(if ($env:CERT_BASE_URL) { $env:CERT_BASE_URL } else { "https://certificates.api.stackit.cloud/v2" }),
[string] $AcmeServer = $(if ($env:ACME_SERVER) { $env:ACME_SERVER } else { "https://acme-staging-v02.api.letsencrypt.org/directory" }),
# Advanced: CNAME Delegation Mode configuration
[bool] $UseChallengeDelegation = $(if ($env:USE_CHALLENGE_DELEGATION -match '^(true|1|yes)$') { $true } else { $false }),
[string] $VerifyZoneFQDN = $env:VERIFY_ZONE_FQDN
)
$ErrorActionPreference = "Stop"
Write-Output "=== STACKIT ALB Certificate Auto-Renewal Pipeline ==="
# Parse whitelist from environment if not provided via parameter
if ($DomainWhitelist.Count -eq 0 -and $env:DOMAIN_WHITELIST) {
$DomainWhitelist = $env:DOMAIN_WHITELIST -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
}
$Config = @{
ProjectId = $ProjectId
RegionId = $RegionId
AlbName = $AlbName
DomainWhitelist = $DomainWhitelist
CertbotLive = $CertbotLive
DNS_SAKeyPath = $DNS_SAKeyPath
ALB_SAKeyPath = $ALB_SAKeyPath
SkipCertbot = $SkipCertbot
DaysWarning = $DaysWarning
AlbBaseUrl = $AlbBaseUrl
CertBaseUrl = $CertBaseUrl
AcmeServer = $AcmeServer
UseChallengeDelegation = $UseChallengeDelegation
VerifyZoneFQDN = $VerifyZoneFQDN
}
# --- PRE-FLIGHT CHECKS ---
Write-Output "`n>>> [INIT] Starting pre-flight checks..."
# CI/CD Integration: Decode Base64 encoded Service Account keys injected via Environment Variables.
# This avoids storing physical JSON files in the repository or mounting them insecurely.
if ($env:ALB_SA_KEY_B64) {
Write-Output " [INFO] Decoding ALB Service Account from Base64 variable..."
$albBytes = [System.Convert]::FromBase64String($env:ALB_SA_KEY_B64.Trim())
$Config.ALB_SAKeyPath = Join-Path ([System.IO.Path]::GetTempPath()) "alb-sa.json"
[System.IO.File]::WriteAllBytes($Config.ALB_SAKeyPath, $albBytes)
}
if ($env:DNS_SA_KEY_B64) {
Write-Output " [INFO] Decoding DNS Service Account from Base64 variable..."
$dnsBytes = [System.Convert]::FromBase64String($env:DNS_SA_KEY_B64.Trim())
$Config.DNS_SAKeyPath = Join-Path ([System.IO.Path]::GetTempPath()) "dns-sa.json"
[System.IO.File]::WriteAllBytes($Config.DNS_SAKeyPath, $dnsBytes)
}
# Verify that key files exist (either provided directly or decoded above)
if (-not (Test-Path $Config.DNS_SAKeyPath)) {
Write-Error "[FATAL] DNS Service Account Key missing at $($Config.DNS_SAKeyPath)"
exit 1
}
if (-not (Test-Path $Config.ALB_SAKeyPath)) {
Write-Error "[FATAL] ALB Service Account Key missing at $($Config.ALB_SAKeyPath)"
exit 1
}
if ($Config.UseChallengeDelegation -and [string]::IsNullOrWhiteSpace($Config.VerifyZoneFQDN)) {
Write-Error "[FATAL] Challenge Delegation is enabled, but VERIFY_ZONE_FQDN is empty!"
exit 1
}
# Import helper functions
. "$PSScriptRoot/lib/Get-StackitAlbCertStatus_CLI.ps1"
. "$PSScriptRoot/lib/StackitHelper_CLI.ps1"
Write-Output "[OK] Pre-flight checks passed."
# --- STEP 1: AUTHENTICATION ---
Write-Output "`n>>> [STEP 1] Activating STACKIT CLI Service Account (ALB Scope)..."
& stackit auth activate-service-account --service-account-key-path $Config.ALB_SAKeyPath
if ($LASTEXITCODE -ne 0) { throw "Failed to activate STACKIT CLI Service Account for ALB operations." }
Write-Output "[OK] STACKIT CLI successfully authenticated."
# --- STEP 2: STATUS CHECK ---
# Query the ALB configuration to map domains to active certificates and calculate expiration
Write-Output "`n>>> [STEP 2] Fetching ALB configuration and certificate status..."
$status = Get-StackitAlbCertStatus -ProjectId $Config.ProjectId -RegionId $Config.RegionId -AlbName $Config.AlbName -Whitelist $Config.DomainWhitelist -DaysWarning $Config.DaysWarning -AlbBaseUrl $Config.AlbBaseUrl -CertBaseUrl $Config.CertBaseUrl
$allCerts = @($status.certificates)
$toRenew = @($allCerts | Where-Object { $_.shouldReplace -eq $true })
$healthyCerts = @($allCerts | Where-Object { $_.shouldReplace -eq $false })
Write-Output "[INFO] Evaluated a total of $($allCerts.Count) active certificate(s) on ALB '$($Config.AlbName)'."
# Sub-Check 2a: Warn if a whitelisted domain is missing from the ALB configuration
if ($Config.DomainWhitelist.Count -gt 0) {
$albDomains = $allCerts.domain
foreach ($wlDomain in $Config.DomainWhitelist) {
if ($albDomains -notcontains $wlDomain) {
Write-Warning " [SKIP] Domain '$wlDomain' is on the whitelist, but is NOT configured on the ALB. Skipping..."
}
}
}
# Sub-Check 2b: Log healthy certificates that do not require renewal yet
if ($healthyCerts.Count -gt 0) {
foreach ($hc in $healthyCerts) {
Write-Output " [SKIP] Certificate for '$($hc.domain)' is healthy (Expires in $($hc.daysUntilExpiry) days). No action required."
}
}
if ($toRenew.Count -eq 0) {
Write-Output "`n[RESULT] All targeted certificates are healthy and up to date. No renewals required."
Write-Output "=== Workflow Completed SUCCESSFULLY ==="
exit 0
}
# --- STEP 3 & 4: RENEWAL LOOP ---
Write-Output "`n>>> [STEP 3] Starting renewal process for $($toRenew.Count) certificate(s)..."
$WorkflowHasErrors = $false
foreach ($cert in $toRenew) {
Write-Output "`n--------------------------------------------------------"
Write-Output "[*] Target Domain : $($cert.domain)"
Write-Output "[*] Expiring Cert : $($cert.certificateId)"
Write-Output "--------------------------------------------------------"
$certbotSuccess = $false
$domainPath = Join-Path $Config.CertbotLive $cert.domain
$fullchainPath = Join-Path $domainPath "fullchain.pem"
$privkeyPath = Join-Path $domainPath "privkey.pem"
if (-not $Config.SkipCertbot) {
Write-Output " -> [Action] Triggering Certbot ACME DNS-01 challenge..."
# --- CERTBOT EXTERNAL LOG BOUNDARY ---
Write-Output ""
Write-Output " v===================== CERTBOT OUTPUT =====================v"
if ($Config.UseChallengeDelegation) {
Write-Output " -> [INFO] Mode: CNAME Delegation Hooks (Verify Zone: $($Config.VerifyZoneFQDN))"
# Export variables required by the child processes (the PowerShell Hook scripts)
$env:STACKIT_PROJECT_ID = $Config.ProjectId
$env:DNS_SA_KEY_PATH = $Config.DNS_SAKeyPath
$env:VERIFY_ZONE_FQDN = $Config.VerifyZoneFQDN
$PSExe = if ($PSVersionTable.PSVersion.Major -ge 6) { "pwsh" } else { "powershell" }
$AuthHookPath = Join-Path $PSScriptRoot "lib\Stackit-DnsHook.ps1"
$CleanupHookPath = Join-Path $PSScriptRoot "lib\Stackit-CleanupHook.ps1"
$AuthCmd = "$PSExe -NoProfile -ExecutionPolicy Bypass -File `"$AuthHookPath`""
$CleanupCmd = "$PSExe -NoProfile -ExecutionPolicy Bypass -File `"$CleanupHookPath`""
$CertbotArgs = @(
"certonly",
"--manual",
"--manual-auth-hook", $AuthCmd,
"--manual-cleanup-hook", $CleanupCmd,
"--preferred-challenges", "dns",
"--server", $Config.AcmeServer,
"--agree-tos",
"-d", $cert.domain,
"--non-interactive",
"--force-renewal",
"--disable-hook-validation"
)
& certbot $CertbotArgs
} else {
Write-Output " -> [INFO] Mode: STACKIT DNS Plugin (Direct Zone Update)"
# Execute Certbot using the official STACKIT DNS plugin
& certbot certonly --authenticator dns-stackit `
--dns-stackit-project-id $Config.ProjectId `
--dns-stackit-service-account $Config.DNS_SAKeyPath `
--dns-stackit-propagation-seconds 120 `
--server $Config.AcmeServer `
--agree-tos -d $cert.domain --non-interactive --force-renewal
}
$certbotSuccess = ($LASTEXITCODE -eq 0)
# --- END OF CERTBOT LOG BOUNDARY ---
Write-Output " ^==========================================================^"
Write-Output ""
if (-not $certbotSuccess) {
Write-Error " [FAIL] Certbot process failed for $($cert.domain)."
$WorkflowHasErrors = $true
continue
}
} else {
# SkipCertbot mode: Assume files were generated out-of-band and just deploy them
Write-Output " -> [INFO] SkipCertbot enabled. Checking for existing local files..."
$certbotSuccess = (Test-Path $fullchainPath) -and (Test-Path $privkeyPath)
if (-not $certbotSuccess) {
Write-Error " [FAIL] Local certificate files not found for $($cert.domain)!"
$WorkflowHasErrors = $true
continue
}
}
Write-Output " [+] Certificate generated successfully. Local files are ready."
# Generate a unique name for the new certificate in STACKIT Certificate Manager
$safeDomain = $cert.domain -replace '\.', '-'
$newName = "auto-$($safeDomain)-$(Get-Date -Format 'yyyyMMdd-HHmm')"
try {
# Reactivate ALB scope auth (in case Certbot/Hooks altered the active CLI session)
Write-Output " -> [Action] Ensuring STACKIT CLI Auth is active before upload..."
& stackit auth activate-service-account --service-account-key-path $Config.ALB_SAKeyPath
Write-Output " -> [Action] Uploading new certificate '$newName' to STACKIT Certificate Manager in project '$($Config.ProjectId)'"
$newCert = New-StackitCertificate -ProjectId $Config.ProjectId -RegionId $Config.RegionId -CertName $newName -FullchainPath $fullchainPath -PrivkeyPath $privkeyPath -CertBaseUrl $Config.CertBaseUrl
Write-Output " [SUCCESS] Certificate successfully uploaded!"
Write-Output " -> [Action] Patching ALB to use new Certificate ID: $($newCert.id)..."
$patch = Update-AlbListenerCert -ProjectId $Config.ProjectId -RegionId $Config.RegionId -AlbName $Config.AlbName -OldCertId $cert.certificateId -NewCertId $newCert.id -AlbBaseUrl $Config.AlbBaseUrl
Write-Output " [SUCCESS] ALB successfully updated with the new certificate!"
} catch {
Write-Error " [ERROR] STACKIT platform update failed for $($cert.domain): $($_.Exception.Message)"
$WorkflowHasErrors = $true
}
}
if ($WorkflowHasErrors) {
Write-Error "`n=== Workflow Completed with ERRORS ==="
Write-Error "At least one certificate failed to renew or deploy. Please check the logs above."
exit 1
} else {
Write-Output "`n=== Workflow Completed SUCCESSFULLY ==="
exit 0
}

View file

@ -0,0 +1,94 @@
<#
.SYNOPSIS
Evaluates STACKIT ALB Certificate Expiration
.DESCRIPTION
This helper script retrieves the current configuration of the STACKIT Application Load Balancer.
It extracts the IDs of all bound certificates, retrieves their public keys from the Certificate Manager,
and calculates the exact expiration dates using native X509 .NET classes.
#>
function Get-StackitAlbCertStatus {
param (
[Parameter(Mandatory=$true)] [string]$ProjectId,
[Parameter(Mandatory=$true)] [string]$RegionId,
[Parameter(Mandatory=$true)] [string]$AlbName,
[Parameter(Mandatory=$false)] [string[]]$Whitelist = @(),
[Parameter(Mandatory=$false)] [int]$DaysWarning = 30,
[Parameter(Mandatory=$false)] [string]$AlbBaseUrl = "https://alb.api.stackit.cloud/v2",
[Parameter(Mandatory=$false)] [string]$CertBaseUrl = "https://certificates.api.stackit.cloud/v2",
[Parameter(Mandatory=$false)] [switch]$ForceRenew
)
$CertResults = New-Object System.Collections.Generic.List[PSCustomObject]
try {
# 1. Fetch current ALB state
$AlbUrl = "$AlbBaseUrl/projects/$ProjectId/regions/$RegionId/load-balancers/$AlbName"
$Alb = stackit curl -X GET $AlbUrl | ConvertFrom-Json
# 2. Iterate through listeners and extract bound certificate IDs
foreach ($Listener in $Alb.listeners) {
if ($Listener.protocol -eq "PROTOCOL_HTTPS" -and $Listener.https.certificateConfig.certificateIds) {
foreach ($HostRule in $Listener.http.hosts) {
# Apply whitelist filtering if specified
if ($Whitelist.Count -eq 0 -or $Whitelist -contains $HostRule.host) {
foreach ($CertId in $Listener.https.certificateConfig.certificateIds) {
# Bypass calculation if forced renewal is triggered
if ($ForceRenew) {
$CertResults.Add([PSCustomObject]@{
domain = $HostRule.host
certificateId = $CertId
name = "Forced-Check"
expiryDate = "FORCED"
daysLeft = 0
shouldReplace = $true
})
continue
}
# 3. Retrieve actual certificate details from STACKIT Certificate Manager
$CertUrl = "$CertBaseUrl/projects/$ProjectId/regions/$RegionId/certificates/$CertId"
try {
$CertJson = stackit curl -X GET $CertUrl | ConvertFrom-Json
# Note: The STACKIT API returns the public key inside a JSON string.
# We must extract the raw PEM, strip headers, and sanitize it for Base64 conversion.
$rawCert = $CertJson.publicKey
$firstCertPart = ($rawCert -split "-----END CERTIFICATE-----")[0]
$cleanB64 = ($firstCertPart -replace "-----BEGIN CERTIFICATE-----", "") -replace '[^a-zA-Z0-9\+\/=]', ''
# Pad Base64 string if necessary to avoid format exceptions
while ($cleanB64.Length % 4 -ne 0) { $cleanB64 += "=" }
# Load the byte array into a .NET X509 object to reliably read the expiration date (NotAfter)
$certBytes = [System.Convert]::FromBase64String($cleanB64)
$x509 = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certBytes)
$CertResults.Add([PSCustomObject]@{
domain = $HostRule.host
certificateId = $CertId
name = $CertJson.name
expiryDate = $x509.NotAfter.ToString("yyyy-MM-dd")
daysLeft = ($x509.NotAfter - (Get-Date)).Days
shouldReplace = (($x509.NotAfter - (Get-Date)).Days -lt $DaysWarning)
})
} catch {
$CertResults.Add([PSCustomObject]@{ domain = $HostRule.host; error = "CLI Error processing $CertId" })
}
}
}
}
}
}
# Return unique certificates (a cert might be bound to multiple listeners)
return [PSCustomObject]@{
projectId = $ProjectId;
albName = $AlbName;
certificates = $CertResults | Select-Object -Unique * }
} catch {
throw "ALB Request via STACKIT CLI failed: $($_.Exception.Message)"
}
}

View file

@ -0,0 +1,70 @@
<#
.SYNOPSIS
Certbot Manual Cleanup Hook for STACKIT DNS Validation
.DESCRIPTION
This script is invoked automatically by Certbot after the Let's Encrypt validation
attempt (whether successful or not). It ensures the temporary TXT challenge
records are removed from the STACKIT Verification Zone to maintain a clean DNS state.
#>
$Domain = $env:CERTBOT_DOMAIN
$ProjectId = $env:STACKIT_PROJECT_ID
$SaKeyPath = $env:DNS_SA_KEY_PATH
$VerifyZoneFQDN = $env:VERIFY_ZONE_FQDN
$ErrorActionPreference = "Stop"
$MissingVars = @()
if ([string]::IsNullOrWhiteSpace($Domain)) { $MissingVars += "CERTBOT_DOMAIN" }
if ([string]::IsNullOrWhiteSpace($ProjectId)) { $MissingVars += "STACKIT_PROJECT_ID" }
if ([string]::IsNullOrWhiteSpace($VerifyZoneFQDN)) { $MissingVars += "VERIFY_ZONE_FQDN" }
if ($MissingVars.Count -gt 0) {
[Console]::Error.WriteLine(">>> [HOOK FATAL] Missing required environment variables from Certbot/Main Script: $($MissingVars -join ', ')")
exit 1
}
try {
Write-Output ">>> [CLEANUP] Starting DNS Cleanup Hook for: $Domain"
$RecordName = $Domain
# Suppress STACKIT CLI loading spinners from stderr to prevent Certbot logging errors
$authOutput = & stackit auth activate-service-account --service-account-key-path "$SaKeyPath" 2>&1
if ($LASTEXITCODE -ne 0) { throw "STACKIT CLI Auth failed in Cleanup Hook." }
$ZonesJson = & stackit dns zone list --project-id "$ProjectId" --output-format json 2>$null | ConvertFrom-Json
$Zone = $ZonesJson | Where-Object { $_.dnsName -eq "$VerifyZoneFQDN" -or $_.dnsName -eq "$VerifyZoneFQDN." }
if (-not $Zone) { throw "Verify Zone '$VerifyZoneFQDN' not found in STACKIT Project!" }
$ZoneId = $Zone.id
Write-Output ">>> [CLEANUP] Fetching record sets for zone $ZoneId..."
$RecordsJson = & stackit dns record-set list --zone-id "$ZoneId" --project-id "$ProjectId" --output-format json 2>$null | ConvertFrom-Json
# Account for FQDNs returned with or without a trailing dot
$ExpectedNameWithDot = "$RecordName.$VerifyZoneFQDN."
$ExpectedNameNoDot = "$RecordName.$VerifyZoneFQDN"
$TargetRecord = $RecordsJson | Where-Object {
($_.name -eq $ExpectedNameWithDot -or $_.name -eq $ExpectedNameNoDot) -and $_.type -eq "TXT"
} | Select-Object -First 1
if ($TargetRecord) {
$RecordId = $TargetRecord.id
Write-Output ">>> [CLEANUP] Found TXT record with ID '$RecordId'. Deleting..."
# Suppress CLI auto-confirm warnings from stderr
$deleteOutput = & stackit dns record-set delete "$RecordId" --zone-id "$ZoneId" --project-id "$ProjectId" --assume-yes 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to delete record: $deleteOutput"
}
Write-Output ">>> [CLEANUP] Done."
} else {
Write-Output ">>> [CLEANUP] WARNING: TXT record for '$RecordName' not found. It may have been already deleted."
}
} catch {
[Console]::Error.WriteLine(">>> [CLEANUP ERROR] $($_.Exception.Message)")
exit 1
}

View file

@ -0,0 +1,75 @@
<#
.SYNOPSIS
Certbot Manual Auth Hook for STACKIT DNS Validation
.DESCRIPTION
This script is invoked automatically by Certbot during the DNS-01 challenge.
It takes the validation token provided by Let's Encrypt and creates a temporary
TXT record in a designated STACKIT Verification Zone (Delegation Mode).
#>
# Certbot automatically injects these environment variables
$Domain = $env:CERTBOT_DOMAIN
$ValidationToken = $env:CERTBOT_VALIDATION
# Variables passed down from the Main Orchestrator script
$ProjectId = $env:STACKIT_PROJECT_ID
$SaKeyPath = $env:DNS_SA_KEY_PATH
$VerifyZoneFQDN = $env:VERIFY_ZONE_FQDN
$ErrorActionPreference = "Stop"
# Ensure all required inputs are present
$MissingVars = @()
if ([string]::IsNullOrWhiteSpace($Domain)) { $MissingVars += "CERTBOT_DOMAIN" }
if ([string]::IsNullOrWhiteSpace($ProjectId)) { $MissingVars += "STACKIT_PROJECT_ID" }
if ([string]::IsNullOrWhiteSpace($VerifyZoneFQDN)) { $MissingVars += "VERIFY_ZONE_FQDN" }
if ($MyInvocation.MyCommand.Name -match "DnsHook" -and [string]::IsNullOrWhiteSpace($ValidationToken)) {
$MissingVars += "CERTBOT_VALIDATION"
}
if ($MissingVars.Count -gt 0) {
# We use [Console]::Error.WriteLine to ensure Certbot directly catches fatal hook errors
[Console]::Error.WriteLine(">>> [HOOK FATAL] Missing required environment variables from Certbot/Main Script: $($MissingVars -join ', ')")
exit 1
}
try {
Write-Output ">>> [HOOK] Starting DNS Auth Hook for: $Domain"
$RecordName = $Domain
# Authenticate via STACKIT CLI using the dedicated DNS Service Account.
# CRITICAL: We redirect stream 2 (`2>&1`) to a variable. The CLI may output loading spinners
# to stderr, which Certbot falsely interprets as a critical script failure.
$authOutput = & stackit auth activate-service-account --service-account-key-path "$SaKeyPath" 2>&1
if ($LASTEXITCODE -ne 0) {
throw "STACKIT CLI Auth failed in Hook: $authOutput"
}
# Fetch the ID of the Verification Zone.
# `2>$null` suppresses CLI warnings to ensure ConvertFrom-Json receives a clean JSON payload.
$ZonesJson = & stackit dns zone list --project-id "$ProjectId" --output-format json 2>$null | ConvertFrom-Json
$Zone = $ZonesJson | Where-Object { $_.dnsName -eq "$VerifyZoneFQDN" -or $_.dnsName -eq "$VerifyZoneFQDN." }
if (-not $Zone) {
throw "Verify Zone '$VerifyZoneFQDN' not found in STACKIT Project!"
}
$ZoneId = $Zone.id
Write-Output ">>> [HOOK] Creating TXT Record: '$RecordName' in Zone: '$VerifyZoneFQDN'"
$QuotedToken = "`"$ValidationToken`""
# Create the TXT record. Stream 2 is captured again to suppress CLI auto-confirm warnings.
$createOutput = & stackit dns record-set create --zone-id "$ZoneId" --name "$RecordName" --type "TXT" --record "$QuotedToken" --project-id "$ProjectId" --assume-yes 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to create TXT record: $createOutput"
}
# Let's Encrypt requires time for the DNS entry to propagate across global nameservers
Write-Output ">>> [HOOK] Sleeping 30 seconds to allow DNS propagation..."
Start-Sleep -Seconds 30
} catch {
[Console]::Error.WriteLine(">>> [HOOK ERROR] $($_.Exception.Message)")
exit 1
}

View file

@ -0,0 +1,127 @@
<#
.SYNOPSIS
STACKIT API Interaction Helpers
.DESCRIPTION
Provides functions to interact with the STACKIT Certificate Manager and the
Application Load Balancer API to deploy newly generated certificates.
#>
function New-StackitCertificate {
<#
.SYNOPSIS
Uploads a local PEM certificate and private key to STACKIT Certificate Manager.
#>
param ($ProjectId, $RegionId, $CertName, $FullchainPath, $PrivkeyPath, $CertBaseUrl)
# Read the PEM files and normalize line endings (CRLF to LF) for API compatibility
$cert = (Get-Content -Raw $FullchainPath) -replace "`r`n", "`n"
$key = (Get-Content -Raw $PrivkeyPath) -replace "`r`n", "`n"
# Construct the JSON payload expected by STACKIT
$body = @{
name = $CertName
publicKey = $cert.Trim()
privateKey = $key.Trim()
} | ConvertTo-Json
$url = "$CertBaseUrl/projects/$ProjectId/regions/$RegionId/certificates"
# Store payload in a temporary file to safely pass it to the curl wrapper
$tempFile = New-TemporaryFile
try {
# Ensure UTF8 encoding without Byte Order Mark (BOM) to prevent parsing errors
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($tempFile.FullName, $body, $utf8NoBom)
$response = stackit curl -X POST $url --data "@$($tempFile.FullName)"
if ($LASTEXITCODE -ne 0) {
throw "Failed to upload new certificate '$CertName' to STACKIT Certificate Manager. API Output: $response"
}
return $response | ConvertFrom-Json
}
catch {
Write-Error "[ERROR] New-StackitCertificate: $($_.Exception.Message)"
throw
}
finally {
if (Test-Path $tempFile.FullName) {
Remove-Item -Path $tempFile.FullName -Force
}
}
}
function Update-AlbListenerCert {
<#
.SYNOPSIS
Patches an existing Application Load Balancer to use a newly uploaded certificate ID.
#>
param ($ProjectId, $RegionId, $AlbName, $OldCertId, $NewCertId, $AlbBaseUrl)
$url = "$AlbBaseUrl/projects/$ProjectId/regions/$RegionId/load-balancers/$AlbName"
# 1. Retrieve the complete, current state of the ALB
$alb = stackit curl -X GET $url | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) {
throw "Failed to retrieve current configuration for ALB '$AlbName' from STACKIT API."
}
# 2. Iterate through listeners and replace the old Certificate ID with the new one
$changed = $false
foreach ($l in $alb.listeners) {
if ($l.protocol -eq "PROTOCOL_HTTPS" -and $l.https.certificateConfig) {
$ids = $l.https.certificateConfig.certificateIds
for ($i=0; $i -lt $ids.Count; $i++) {
if ($ids[$i] -eq $OldCertId) {
$ids[$i] = $NewCertId
$changed = $true
}
}
}
}
# 3. If modifications were made, we must push the entire state back via PUT
if ($changed) {
# CRITICAL: The GET request returns read-only state variables.
# Sending these back in a PUT request will cause the STACKIT API to reject the payload.
# We must explicitly strip them from the object.
$alb.PSObject.Properties.Remove("status")
$alb.PSObject.Properties.Remove("loadBalancerSecurityGroup")
$alb.PSObject.Properties.Remove("targetSecurityGroup")
if ($alb.options -and $alb.options.ephemeralAddress -eq $true) {
$alb.PSObject.Properties.Remove("externalAddress")
}
# Convert back to JSON, ensuring nested arrays (like listeners) are not truncated
$body = $alb | ConvertTo-Json -Depth 20
$tempFile = New-TemporaryFile
try {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($tempFile.FullName, $body, $utf8NoBom)
$response = stackit curl -X PUT $url --data "@$($tempFile.FullName)"
if ($LASTEXITCODE -ne 0) {
throw "Failed to update listener configuration for ALB '$AlbName' with new Certificate ID '$NewCertId'. API Output: $response"
}
return $true
}
catch {
Write-Error "[ERROR] Update-AlbListenerCert: $($_.Exception.Message)"
throw
}
finally {
if (Test-Path $tempFile.FullName) {
Remove-Item -Path $tempFile.FullName -Force
}
}
}
return $false
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,39 @@
#cloud-config
# Docker host bootstrap for ALB + Certbot / ACME DNS-01 workshop
# Target OS: Debian 12 (bookworm)
# Rendered via Terraform templatefile() — variables: start_nginx_test_container
package_update: true
package_upgrade: false
packages:
- ca-certificates
- curl
- gnupg
- apt-transport-https
runcmd:
# Add Docker's official GPG key
- install -m 0755 -d /etc/apt/keyrings
- curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
- chmod a+r /etc/apt/keyrings/docker.asc
# Add Docker apt repository for Debian
- >-
. /etc/os-release &&
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/debian $${VERSION_CODENAME} stable"
| tee /etc/apt/sources.list.d/docker.list > /dev/null
- apt-get update -y
# Install Docker Engine + Compose plugin
- >-
DEBIAN_FRONTEND=noninteractive apt-get install -y
docker-ce docker-ce-cli containerd.io
docker-buildx-plugin docker-compose-plugin
- systemctl enable --now docker
# Add default Debian cloud user to docker group
- usermod -aG docker debian
%{ if start_nginx_test_container ~}
# Start nginx test container — verify with: curl http://<vm-ip>
- docker run -d --name nginx-test --restart unless-stopped -p 80:80 nginx:alpine
%{ endif ~}
final_message: "Cloud-init complete after $UPTIME seconds. Docker host is ready."

View file

@ -0,0 +1,20 @@
**/.terraform/*
*.tfstate
*.tfstate.*
*.tfvars
*.tfvars.json
.terraform.tfstate.lock.info
crash.log
crash.*.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
.terraformrc
terraform.rc
keys/*
backend.conf
certs/
.DS_Store
*.bkp
.idea

View file

@ -0,0 +1 @@
v1.5.7

View file

@ -0,0 +1,45 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/tls" {
version = "4.3.0"
constraints = "~> 4.0"
hashes = [
"h1:5bCU/c+2HUh7GhclzNSH6gAuoCS4inW3obEtRAwu6WQ=",
"zh:0ab58d6f8991d436c7d2dbd89ed814709b949b07ac5a54ee53b0aec1fa772a8b",
"zh:60b347abcb56f45d97c56f14d895069cd15a83993f199777f571b79fea3642ee",
"zh:6889be32640349230de3f23856e6f04e0e9ced4a84a27d3f552fa54684448218",
"zh:73f8e1ecf7135033165fb14b7e8bf4d656f3ce13065ec35762ea0481975328c7",
"zh:94ce25ee253eca0b42cae9c856b36bca8103b6453012d1b279c3623c805f2d42",
"zh:96bc6de9fd67bc446fd11257872e1ffb1029a996ed1d65a3f6b43f6d408ad9ab",
"zh:97c609a310a51bfd504d704e036d72064a84bf0bdb36cc08cd4cc66098212b41",
"zh:a12c16e94533c5bd123f75032576b9dc91dd5d5ccd5f7cf331d0f2e1adc55cf8",
"zh:c4f014f876adf7af57188795050bda5b0029d8c7d7773031102b6c36dcf1fc21",
"zh:d9b0a21583aaa3df3a95394fb949a3c515ff71c2ff5a1fc4a73d364aa90bfca5",
"zh:da510d22f0c6d71ad19a76406f106b782448f512375787ecfabb338ed1e311a7",
"zh:f0e9447a9ce3a24cdaa113089e65663c836d8b9bfdb915a1c0284e0112cab5c0",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}
provider "registry.terraform.io/stackitcloud/stackit" {
version = "0.98.0"
constraints = ">= 0.98.0"
hashes = [
"h1:/FB0wBnvmjumjykX+j90kSck6LMScDaYo1STO5Vp/kw=",
"zh:031028340fbaeeb5c4c6b1d5c6d6287a70cf253cfb89f04d462a1c0ab6237ffc",
"zh:0dde99e7b343fa01f8eefc378171fb8621bedb20f59157d6cc8e3d46c738105f",
"zh:0eee18f9a262fa58966c960f1f0863eed92cd953d0f0306ecc456b58cc2911f8",
"zh:1646966ebac0eb5d6c78ac5aa1528921d7a635f14d81300463a402c55e33cfd3",
"zh:5374ab9e5e6d837787b4f18bcf0125a1bf3ee2da40c022cc7695d6879fed111b",
"zh:6a5b9e1307055f8d358373da625ffcb4d77ec44f260d14473b10e5777380765e",
"zh:6c90090504474695ab7290d64386dd988f4fb65c90c74c9cf3a6da6226ae8a70",
"zh:8317218828f29be95ce712863646dc8968e146ec14e5ab258cb1e8f8b649245b",
"zh:9eef08e4fb7a75760f9dc8a422446f19a210ebf8177dd5aeb97444295f0120cf",
"zh:9f2147eee63feae75b96f17f3b3ebab8a29cd7164cdd08eb2bb871e5c425a77f",
"zh:b63ea754eea233292fb73d87a9810104da2bd347abf2ca0da44ac76591dcdddb",
"zh:de60bd928828a836e446f9f89e7a3bfc4e6dd73bac6827914087b34e4ad0c978",
"zh:f22d295b2e4e94ae1566e20fd752825e008a62250cf7243f1161c0bf4e986518",
"zh:f7e57bc7be2cc016983ff3ad50d2733b85e90bfaa7aa9e2192563dc9d422fb07",
]
}

View file

@ -0,0 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
terraform {
backend "s3" {}
}

View file

@ -0,0 +1,34 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
terraform {
required_version = ">= 1.3.0"
required_providers {
stackit = {
source = "stackitcloud/stackit"
version = ">= 0.98.0"
}
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
}
provider "stackit" {
default_region = var.stackit_region
service_account_key_path = var.stackit_service_account_key_path
# required for stackit_application_load_balancer (beta resource)
enable_beta_resources = true
}

View file

@ -0,0 +1,176 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Provider
variable "stackit_region" {
description = "STACKIT region, e.g. eu01"
type = string
default = "eu01"
}
variable "stackit_service_account_key_path" {
description = "Path to the STACKIT service account key JSON file"
type = string
default = "keys/sa-key.json"
}
# Resource Hierarchy
variable "organization_id" {
description = "STACKIT organisation container ID — find it in the Portal under Organisation → Settings"
type = string
}
variable "owner_email" {
description = "Email of the resource owner; must be an existing STACKIT user in the organisation"
type = string
}
variable "folder_name" {
description = "Display name of the folder (must match the existing folder name when importing)"
type = string
default = "alb-showcase"
}
variable "project_name" {
description = "Name of the new project to create inside the folder"
type = string
default = "vm-alb-self-signed-cert"
}
# Naming
variable "name_prefix" {
description = "Short prefix applied to all resource names (network, VMs, ALB, certificate)"
type = string
default = "vm-alb-tls"
}
# Network
variable "network_cidr" {
description = "IPv4 CIDR block for the private network, e.g. 10.10.0.0/24"
type = string
default = "10.10.0.0/24"
validation {
condition = can(cidrnetmask(var.network_cidr))
error_message = "network_cidr must be a valid CIDR, e.g. 10.10.0.0/24."
}
}
variable "admin_cidr" {
description = "Source CIDR for SSH access (port 22). Use your own egress IP, e.g. 203.0.113.10/32. Avoid 0.0.0.0/0."
type = string
validation {
condition = can(cidrnetmask(var.admin_cidr))
error_message = "admin_cidr must be a valid CIDR, e.g. 203.0.113.10/32."
}
}
# Compute / VM
variable "machine_type" {
description = "STACKIT machine type for backend VMs — list available: stackit server machine-type list --project-id <id>"
type = string
default = "g1.1"
}
variable "availability_zone" {
description = "Availability zone for the VMs, e.g. eu01-1, eu01-2, eu01-3"
type = string
default = "eu01-1"
}
variable "image_id" {
description = "UUID of the boot image (Debian 12 recommended) — list: stackit image list --all --project-id <id>"
type = string
}
variable "boot_volume_size_gb" {
description = "Root disk size in GB for each backend VM"
type = number
default = 20
validation {
condition = var.boot_volume_size_gb >= 10
error_message = "boot_volume_size_gb must be at least 10 GB."
}
}
variable "keypair_name" {
description = "Name of the SSH key pair to register in STACKIT"
type = string
default = "vm-alb-tls-key"
}
variable "ssh_public_key" {
description = "SSH public key string (ssh-ed25519 AAAA... or ssh-rsa AAAA...) — never commit the private key"
type = string
sensitive = true
}
# TLS / Certificate
variable "tls_common_name" {
description = "Common Name (CN) for the self-signed certificate, e.g. alb.example.com or the ALB IP address"
type = string
default = "alb.example.internal"
}
variable "tls_organization" {
description = "Organisation name embedded in the certificate subject"
type = string
default = "STACKIT ALB Showcase"
}
variable "tls_validity_hours" {
description = "Certificate validity in hours (8760 = 1 year)"
type = number
default = 8760
}
# ALB
variable "alb_plan_id" {
description = "ALB service plan — p10 is the smallest available plan"
type = string
default = "p10"
}
variable "alb_acl_ranges" {
description = "Source CIDRs allowed to reach the ALB. Use [\"0.0.0.0/0\"] for public access."
type = list(string)
default = ["0.0.0.0/0"]
}
# DNS
variable "dns_zone_name" {
description = "Human-readable label for the DNS zone resource"
type = string
default = "vm-alb-tls-zone"
}
variable "dns_name" {
description = "DNS zone apex FQDN, e.g. vm-alb-tls.stackit.gg"
type = string
}
variable "dns_contact_email" {
description = "SOA contact email for the DNS zone"
type = string
}

View file

@ -0,0 +1,43 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
locals {
common_labels = {
environment = "showcase"
managed-by = "terraform"
use-case = "vm-alb-self-signed-tls"
}
}
resource "stackit_resourcemanager_folder" "showcase" {
name = var.folder_name
owner_email = var.owner_email
parent_container_id = var.organization_id
labels = {
managed-by = "terraform"
}
lifecycle {
ignore_changes = [labels]
}
}
resource "stackit_resourcemanager_project" "showcase" {
name = var.project_name
owner_email = var.owner_email
parent_container_id = stackit_resourcemanager_folder.showcase.id
labels = local.common_labels
}

View file

@ -0,0 +1,84 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
resource "stackit_network" "main" {
project_id = stackit_resourcemanager_project.showcase.project_id
name = "${var.name_prefix}-network"
ipv4_prefix = var.network_cidr
ipv4_nameservers = ["8.8.8.8", "1.1.1.1"]
routed = true
labels = local.common_labels
}
resource "stackit_security_group" "vm" {
project_id = stackit_resourcemanager_project.showcase.project_id
name = "${var.name_prefix}-vm-sg"
description = "Security group for the showcase VM"
stateful = true
labels = local.common_labels
}
resource "stackit_security_group_rule" "ssh_ingress" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "ingress"
description = "SSH from admin CIDR only"
protocol = { name = "tcp" }
port_range = { min = 22, max = 22 }
ip_range = var.admin_cidr
}
resource "stackit_security_group_rule" "http_ingress" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "ingress"
description = "HTTP port 80 — ALB forwards traffic here after TLS termination"
protocol = { name = "tcp" }
port_range = { min = 80, max = 80 }
ip_range = "0.0.0.0/0"
}
resource "stackit_security_group_rule" "egress_tcp" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "egress"
description = "Allow all outbound TCP (Docker image pulls, apt)"
protocol = { name = "tcp" }
ip_range = "0.0.0.0/0"
}
resource "stackit_security_group_rule" "egress_udp" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "egress"
description = "Allow all outbound UDP (DNS)"
protocol = { name = "udp" }
ip_range = "0.0.0.0/0"
}
resource "stackit_security_group_rule" "egress_icmp" {
project_id = stackit_resourcemanager_project.showcase.project_id
security_group_id = stackit_security_group.vm.security_group_id
direction = "egress"
description = "Allow outbound ICMP"
protocol = { name = "icmp" }
ip_range = "0.0.0.0/0"
}

View file

@ -0,0 +1,67 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
resource "stackit_key_pair" "showcase" {
name = var.keypair_name
public_key = chomp(var.ssh_public_key)
labels = local.common_labels
}
resource "stackit_network_interface" "vm" {
project_id = stackit_resourcemanager_project.showcase.project_id
network_id = stackit_network.main.network_id
name = "${var.name_prefix}-vm-nic"
security_group_ids = [stackit_security_group.vm.security_group_id]
# The ALB injects its own target security group into this NIC after creation.
lifecycle {
ignore_changes = [security_group_ids]
}
}
resource "stackit_public_ip" "vm" {
project_id = stackit_resourcemanager_project.showcase.project_id
network_interface_id = stackit_network_interface.vm.network_interface_id
labels = local.common_labels
}
resource "stackit_server" "vm" {
project_id = stackit_resourcemanager_project.showcase.project_id
name = "${var.name_prefix}-vm"
machine_type = var.machine_type
availability_zone = var.availability_zone
keypair_name = stackit_key_pair.showcase.name
user_data = file("${path.root}/templates/cloud-init.yaml.tpl")
boot_volume = {
source_type = "image"
source_id = var.image_id
size = var.boot_volume_size_gb
}
network_interfaces = [stackit_network_interface.vm.network_interface_id]
agent = {
provisioning_policy = "ALWAYS"
}
labels = local.common_labels
depends_on = [
stackit_network_interface.vm,
stackit_key_pair.showcase,
]
}

View file

@ -0,0 +1,44 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
resource "tls_private_key" "self_signed" {
algorithm = "RSA"
rsa_bits = 2048
}
resource "tls_self_signed_cert" "self_signed" {
private_key_pem = tls_private_key.self_signed.private_key_pem
subject {
common_name = var.tls_common_name
organization = var.tls_organization
}
dns_names = [var.tls_common_name]
validity_period_hours = var.tls_validity_hours
allowed_uses = [
"key_encipherment",
"digital_signature",
"server_auth",
]
}
resource "stackit_alb_certificate" "self_signed" {
project_id = stackit_resourcemanager_project.showcase.project_id
region = var.stackit_region
name = "${var.name_prefix}-selfsigned-cert"
public_key = tls_self_signed_cert.self_signed.cert_pem
private_key = tls_private_key.self_signed.private_key_pem
}

View file

@ -0,0 +1,31 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
resource "stackit_dns_zone" "showcase" {
project_id = stackit_resourcemanager_project.showcase.project_id
name = var.dns_zone_name
dns_name = var.dns_name
contact_email = var.dns_contact_email
type = "primary"
default_ttl = 300
}
resource "stackit_dns_record_set" "alb_a" {
project_id = stackit_resourcemanager_project.showcase.project_id
zone_id = stackit_dns_zone.showcase.zone_id
name = var.dns_name
type = "A"
ttl = 300
records = [stackit_public_ip.alb.ip]
}

View file

@ -0,0 +1,96 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
resource "stackit_public_ip" "alb" {
project_id = stackit_resourcemanager_project.showcase.project_id
labels = local.common_labels
lifecycle {
ignore_changes = [network_interface_id]
}
}
resource "stackit_application_load_balancer" "main" {
project_id = stackit_resourcemanager_project.showcase.project_id
region = var.stackit_region
name = "${var.name_prefix}-alb"
plan_id = var.alb_plan_id
external_address = stackit_public_ip.alb.ip
networks = [
{
network_id = stackit_network.main.network_id
role = "ROLE_LISTENERS_AND_TARGETS"
}
]
listeners = [
{
name = "https"
port = 443
protocol = "PROTOCOL_HTTPS"
http = {
hosts = [
{
host = "*"
rules = [{ target_pool = "${var.name_prefix}-pool" }]
}
]
}
https = {
certificate_config = {
certificate_ids = [stackit_alb_certificate.self_signed.cert_id]
}
}
},
{
name = "http"
port = 80
protocol = "PROTOCOL_HTTP"
http = {
hosts = [
{
host = "*"
rules = [{ target_pool = "${var.name_prefix}-pool" }]
}
]
}
}
]
target_pools = [
{
name = "${var.name_prefix}-pool"
target_port = 80
targets = [
{
display_name = "${var.name_prefix}-vm"
ip = stackit_network_interface.vm.ipv4
}
]
}
]
options = {
private_network_only = false
access_control = {
allowed_source_ranges = var.alb_acl_ranges
}
}
labels = local.common_labels
depends_on = [stackit_network_interface.vm]
}

View file

@ -0,0 +1,73 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
output "project_id" {
description = "UUID of the created project"
value = stackit_resourcemanager_project.showcase.project_id
}
output "alb_public_ip" {
description = "Public IPv4 address of the Application Load Balancer"
value = stackit_public_ip.alb.ip
}
output "alb_https_url" {
description = "HTTPS URL — certificate is self-signed, use curl -k or accept the browser warning"
value = "https://${stackit_public_ip.alb.ip}"
}
output "alb_http_url" {
description = "Plain HTTP URL for baseline connectivity tests"
value = "http://${stackit_public_ip.alb.ip}"
}
output "vm_public_ip" {
description = "Public IPv4 address of the VM (for SSH access)"
value = stackit_public_ip.vm.ip
}
output "vm_private_ip" {
description = "Private IPv4 address of the VM"
value = stackit_network_interface.vm.ipv4
}
output "ssh_command" {
description = "SSH command to connect to the VM (Debian 12 default user)"
value = "ssh debian@${stackit_public_ip.vm.ip}"
}
output "certificate_id" {
description = "STACKIT ALB certificate ID referenced by the HTTPS listener"
value = stackit_alb_certificate.self_signed.cert_id
}
output "certificate_expiry" {
description = "Certificate expiry timestamp (UTC)"
value = tls_self_signed_cert.self_signed.validity_end_time
}
output "dns_name" {
description = "DNS name pointing to the ALB"
value = var.dns_name
}
output "dns_nameservers" {
description = "Nameservers for the DNS zone — set these at your registrar to delegate the zone"
value = stackit_dns_zone.showcase.primary_name_server
}
output "curl_test" {
description = "curl command to test the HTTPS endpoint by DNS name"
value = "curl -k https://${var.dns_name}"
}

View file

@ -0,0 +1,259 @@
# vm-alb-self-signed-cert
Introductory showcase: a STACKIT VM with Docker nginx, an Application Load Balancer (ALB) in front of it, and a self-signed TLS certificate — fully managed by Terraform, no external tools, no ACME, no Kubernetes.
---
## Architecture
```mermaid
sequenceDiagram
participant User
participant TF as Terraform / STACKIT API
participant TLS as hashicorp/tls
Note over User,TLS: terraform apply
User->>TF: create Folder + Project
TF-->>User: project_id
User->>TF: create Network + Security Group
TF-->>User: network_id
User->>TLS: generate RSA Private Key
TLS-->>User: private_key_pem
User->>TLS: sign Self-Signed Certificate (private_key_pem)
TLS-->>User: cert_pem
User->>TF: upload ALB Certificate (cert_pem + private_key_pem)
TF-->>User: cert_id
User->>TF: create VM (cloud-init → Docker · nginx)
TF-->>User: private_ip · vm_public_ip
User->>TF: create Public IP + DNS Zone + A-Record
TF-->>User: alb_public_ip · zone ready
User->>TF: create ALB (cert_id · private_ip · alb_public_ip)
TF-->>User: ✅ https://your-domain ready
```
```
STACKIT Organisation
└── Folder: alb-showcase
└── Project: vm-alb-self-signed-cert
├── Network: vm-alb-tls-network (10.10.0.0/24, routed)
│ └── Security Group: vm-alb-tls-vm-sg
│ ├── Ingress TCP 22 ← admin_cidr (SSH)
│ ├── Ingress TCP 80 ← 0.0.0.0/0 (ALB backend traffic)
│ └── Egress all → 0.0.0.0/0
├── VM: vm-alb-tls-vm (Debian 12)
│ └── Docker → nginx:alpine :80
├── DNS Zone: vm-alb-tls.stackit.gg
│ └── A-Record → ALB Public IP
└── ALB: vm-alb-tls-alb
├── Public IP (static)
├── Certificate: vm-alb-tls-selfsigned-cert
├── Listener HTTP :80 → vm-alb-tls-pool
└── Listener HTTPS :443 → vm-alb-tls-pool (TLS terminated)
```
---
## Overview
| Component | Description |
| ------------------ | ---------------------------------------------------------------------------------- |
| Resource hierarchy | Folder + Project under an existing STACKIT organisation |
| Network | Private routed network (`10.10.0.0/24`) with security groups |
| Compute | Debian 12 VM with Docker Engine + nginx:alpine |
| Certificate | RSA key + self-signed X.509 cert generated by `hashicorp/tls`, uploaded to STACKIT |
| DNS | Primary zone + A-record pointing to the ALB public IP |
| Load Balancer | ALB with HTTPS :443 (TLS terminates here) and HTTP :80 |
| In this showcase | Not in this showcase |
| ----------------------------- | --------------------------- |
| Self-signed TLS via Terraform | Let's Encrypt / ACME |
| One VM + Docker nginx | Multiple VMs / auto-scaling |
| STACKIT DNS Zone + A-Record | Certificate renewal |
| Fully Terraform-managed | Kubernetes / cert-manager |
→ For Let's Encrypt: [`vm-alb-certbot-letsencrypt/`](../vm-alb-certbot-letsencrypt/README.md)
→ For Kubernetes: [`alb-k8s/`](../alb-k8s/README.md)
---
## Prerequisites
| Tool | Version |
| ----------- | -------- |
| Terraform | >= 1.5.7 |
| STACKIT CLI | latest |
| SSH client | — |
### Required STACKIT permissions
| Service | Role |
| --------------------------------- | -------- |
| Resource Manager (Folder/Project) | `editor` |
| Compute | `editor` |
| Networking | `editor` |
| DNS | `editor` |
| Application Load Balancer | `editor` |
### Required variables
| Variable | Description | Example |
| ------------------- | ---------------------------------- | -------------------------------- |
| `organization_id` | STACKIT organisation container ID | Portal → Organisation → Settings |
| `owner_email` | Owner email for folder and project | `name@example.com` |
| `folder_name` | Name of the existing folder | `alb-showcase` |
| `image_id` | UUID of the Debian 12 boot image | `stackit image list --all` |
| `ssh_public_key` | SSH public key string | `ssh-ed25519 AAAA...` |
| `admin_cidr` | Your IP for SSH access | `203.0.113.10/32` |
| `dns_name` | DNS name for the ALB | `vm-alb-tls.stackit.gg` |
| `dns_contact_email` | SOA contact for the DNS zone | `name@example.com` |
All other variables have sensible defaults — see [`01-variables.tf`](01-variables.tf).
---
## Deployment
### 1. Configure variables
```bash
cp examples/terraform.tfvars.example terraform.tfvars
# Fill in: organization_id, owner_email, image_id, ssh_public_key, admin_cidr, dns_name
```
Find your egress IP for `admin_cidr`:
```bash
curl -s https://ifconfig.schwarz
```
Find available Debian 12 image UUIDs:
```bash
stackit image list --all --project-id <project-id>
```
### 2. Configure remote state
```bash
cp examples/backend.conf.example backend.conf
# Fill in: bucket, key, access_key, secret_key
```
### 3. Deploy
```bash
terraform init -backend-config=backend.conf
terraform plan
terraform apply
```
Duration: ~58 minutes. Expected resources: ~15.
### 4. Outputs
```bash
terraform output
```
---
## Validation
```bash
# HTTPS — -k skips the self-signed cert warning
curl -k https://vm-alb-tls.stackit.gg
# HTTP
curl http://vm-alb-tls.stackit.gg
# Inspect TLS certificate
openssl s_client -connect vm-alb-tls.stackit.gg:443 </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
# SSH to the VM
ssh debian@$(terraform output -raw vm_public_ip)
docker ps
docker logs nginx
```
---
## File Structure
```
vm-alb-self-signed-cert/
├── .terraform-version # Terraform version pin (v1.5.7)
├── .gitignore
├── 00-backend.tf # S3 remote state (STACKIT Object Storage)
├── 00-provider.tf # stackitcloud/stackit + hashicorp/tls
├── 01-variables.tf # All variables with descriptions and defaults
├── 02-resource-hierarchy.tf # locals, folder resource, project
├── 03-network.tf # Network, security group, rules
├── 04-compute.tf # SSH key pair, NIC, VM (Docker nginx)
├── 05-certificate.tf # tls_private_key, tls_self_signed_cert, stackit_alb_certificate
├── 05-dns.tf # DNS zone + A-record
├── 06-alb.tf # Public IP, Application Load Balancer
├── 07-outputs.tf # All outputs (IP, URLs, SSH, cert_id, curl)
├── templates/
│ └── cloud-init.yaml.tpl # Docker Engine install + nginx:alpine container
├── examples/
│ ├── terraform.tfvars.example
│ └── backend.conf.example
├── docs/
│ └── architecture.md # Deployment sequence diagram + component overview
└── keys/ # SA key JSON — gitignored
```
---
## Security
| File | Git status | Contains |
| ------------------ | ---------- | -------------------------- |
| `terraform.tfvars` | gitignored | SSH key, sensitive config |
| `backend.conf` | gitignored | Object Storage access keys |
| `keys/` | gitignored | Service account JSON key |
- SSH access is restricted to `admin_cidr` — never use `0.0.0.0/0`
- The TLS private key is stored in Terraform state — acceptable for a showcase, use KMS/Vault for production
---
## Cleanup
```bash
terraform destroy
```
Removes all resources: ALB, VM, network, certificate, DNS zone, public IP, project.
The parent folder is not destroyed (it pre-exists and was imported into state).
---
## Troubleshooting
| Problem | Cause | Fix |
| ------------------------------- | ------------------------------- | ------------------------------------------------ |
| `curl: (7) Failed to connect` | ALB not ready yet | Wait 12 min |
| `curl: (35) SSL error` | Self-signed cert | Use `-k` flag |
| nginx not responding | Docker image pull still running | Wait 35 min, check `docker ps` on VM |
| DNS not resolving | Zone not yet propagated | Run `dig vm-alb-tls.stackit.gg` and wait |
| Provider error on folder labels | Existing labels after import | `lifecycle { ignore_changes = [labels] }` is set |
---
## References
- [Full architecture details](docs/architecture.md)
- [STACKIT Terraform Provider](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs)
- [STACKIT CLI](https://github.com/stackitcloud/stackit-cli)
- [STACKIT Developer Documentation](https://docs.stackit.cloud)
- [hashicorp/tls Provider](https://registry.terraform.io/providers/hashicorp/tls/latest/docs)

View file

@ -0,0 +1,128 @@
# Architecture: vm-alb-self-signed-cert
## Deployment Sequence
```mermaid
sequenceDiagram
participant User
participant TF as Terraform / STACKIT API
participant TLS as hashicorp/tls
Note over User,TLS: terraform apply
User->>TF: create Folder + Project
TF-->>User: project_id
User->>TF: create Network + Security Group
TF-->>User: network_id
User->>TLS: generate RSA Private Key
TLS-->>User: private_key_pem
User->>TLS: sign Self-Signed Certificate (private_key_pem)
TLS-->>User: cert_pem
User->>TF: upload ALB Certificate (cert_pem + private_key_pem)
TF-->>User: cert_id
User->>TF: create VM (cloud-init → Docker · nginx)
TF-->>User: private_ip · vm_public_ip
User->>TF: create Public IP + DNS Zone + A-Record
TF-->>User: alb_public_ip · zone ready
User->>TF: create ALB (cert_id · private_ip · alb_public_ip)
TF-->>User: ✅ https://your-domain ready
```
---
# VM + ALB + Self-Signed TLS
## Traffic Flow
```
Client
│ DNS lookup: vm-alb-tls.stackit.gg
STACKIT DNS
│ resolves to ALB public IP (Terraform A-record)
│ HTTPS :443
STACKIT ALB (L7, TLS termination)
│ certificate: self-signed (hashicorp/tls, managed by Terraform)
│ HTTP routing to target pool
│ HTTP :80
STACKIT VM (Debian 12, Docker Engine)
Container: nginx:alpine (port 80)
```
The ALB terminates TLS. The VM only receives plain HTTP on port 80 — no
certificate management on the backend.
---
## Component Responsibility
| Component | File | Purpose |
| ------------------------- | -------------------------- | --------------------------------- |
| STACKIT Folder + Project | `02-resource-hierarchy.tf` | Resource boundary |
| Network + Security Group | `03-network.tf` | Private network, SSH + HTTP rules |
| VM (Debian 12) | `04-compute.tf` | Docker host |
| TLS Private Key | `05-certificate.tf` | RSA key pair (hashicorp/tls) |
| Self-Signed Certificate | `05-certificate.tf` | X.509 cert (hashicorp/tls) |
| ALB Certificate resource | `05-certificate.tf` | Uploads cert to STACKIT |
| DNS Zone + A-record | `05-dns.tf` | Zone apex → ALB public IP |
| Application Load Balancer | `06-alb.tf` | L7 TLS termination + HTTP routing |
---
## Certificate Flow
```
hashicorp/tls provider STACKIT
───────────────────── ───────
tls_private_key ─ PEM ─► stackit_alb_certificate ─ cert_id ─► stackit_application_load_balancer
tls_self_signed_cert ─ PEM ─► (certificate store) (HTTPS listener)
```
All steps happen in a single `terraform apply`. No scripts or manual API calls required.
---
## Resource Hierarchy
```
STACKIT Organisation
└── Folder: alb-showcase (imported via terraform import)
└── Project: vm-alb-self-signed-cert (created by Terraform)
├── Network: vm-alb-tls-net (10.10.0.0/24, routed)
│ └── Security Group: vm-alb-tls-vm-sg
│ ├── Ingress TCP 22 ← admin_cidr only
│ ├── Ingress TCP 80 ← 0.0.0.0/0 (ALB backend traffic)
│ └── Egress all → 0.0.0.0/0
├── VM: vm-alb-tls-vm (Debian 12, Docker Engine)
├── Certificate: vm-alb-tls-selfsigned-cert (self-signed)
├── DNS Zone: vm-alb-tls.stackit.gg (primary)
└── ALB: vm-alb-tls-alb
├── Public IP (dedicated, wired to DNS A-record by Terraform)
├── Listener HTTP :80 → VM:80
└── Listener HTTPS :443 → VM:80 (TLS terminated at ALB)
```
---
## Security Notes
| Concern | Status |
| ------------------------------ | ----------------------------------------------------------- |
| Private key in Terraform state | Yes — acceptable for showcase/dev |
| Certificate trust | Self-signed — browsers will warn; use `curl -k` for testing |
| SSH access | Restricted to `admin_cidr` — never use `0.0.0.0/0` |
| Production recommendation | Use `stackit-acme-alb` for Let's Encrypt certs |

View file

@ -0,0 +1,15 @@
bucket = ""
key = ""
region = "eu01"
endpoints = {
s3 = "https://object.storage.eu01.onstackit.cloud"
}
access_key = ""
secret_key = ""
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_s3_checksum = true

View file

@ -0,0 +1,52 @@
# Copy this file to terraform.tfvars and fill in the required values.
# terraform.tfvars is gitignored — never commit real credentials or IDs.
# ─── Provider ─────────────────────────────────────────────────────────────────
stackit_region = "eu01"
stackit_service_account_key_path = "keys/sa-key.json"
# ─── Project ──────────────────────────────────────────────────────────────────
# Find your project ID in the STACKIT Portal or via: stackit project list
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# ─── Naming ───────────────────────────────────────────────────────────────────
name_prefix = "vm-alb-tls"
# ─── Network ──────────────────────────────────────────────────────────────────
network_cidr = "10.10.0.0/24"
# Restrict SSH to your IP — never use 0.0.0.0/0 in production
# Find your IP: curl -s https://ifconfig.schwarz
admin_cidr = "203.0.113.10/32"
# ─── Compute / VM ─────────────────────────────────────────────────────────────
machine_type = "g1.1"
availability_zone = "eu01-1"
# List available Debian 12 images:
# stackit image list --all --project-id <project-id>
image_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume_size_gb = 20
keypair_name = "vm-alb-tls-key"
# Paste your SSH public key here (never the private key)
ssh_public_key = "ssh-ed25519 AAAA... your-key-comment"
# ─── TLS / Certificate ────────────────────────────────────────────────────────
# Use the ALB public IP or a hostname you control.
# A self-signed cert on an IP address is fully valid for testing.
tls_common_name = "alb.example.internal"
tls_organization = "STACKIT ALB Showcase"
tls_validity_hours = 8760 # 1 year
# ─── ALB ──────────────────────────────────────────────────────────────────────
alb_plan_id = "p10"
alb_acl_ranges = ["0.0.0.0/0"]

View file

@ -0,0 +1,37 @@
#cloud-config
package_update: true
package_upgrade: false
packages:
- ca-certificates
- curl
- gnupg
write_files:
- path: /opt/install-docker.sh
permissions: "0755"
content: |
#!/bin/bash
set -euo pipefail
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y \
docker-ce docker-ce-cli containerd.io docker-compose-plugin
systemctl enable --now docker
usermod -aG docker debian
docker run -d --name nginx --restart unless-stopped -p 80:80 nginx:alpine
runcmd:
- /opt/install-docker.sh
final_message: "Cloud-init complete after $UPTIME seconds."

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#cloud-config
users:
- name: debug

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#cloud-config
users:
- name: debug

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#cloud-config
# Update apt database on first boot (run 'apt-get update').
# Note, if packages are given, or package_upgrade is true, then

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#cloud-config
users:
- name: Administrator

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#cloud-config
# ---------------------------------------------------------------------------
# Example cloud-init for Linux instances (RHEL / Debian).

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#cloud-config
# ---------------------------------------------------------------------------
# Example cloud-init for Windows Server instances.

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
kind: PersistentVolumeClaim
apiVersion: v1
metadata:

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: v1
kind: Service
metadata:

View file

@ -1,3 +1,17 @@
# Copyright 2026 Schwarz Digits Cloud GmbH & Co. KG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#cloud-config
# ---------------------------------------------------------------------------
# Example cloud-init for Linux instances (RHEL / Debian).