In this post, I'll walk you through how I deployed a URL shortener app, with a managed PostgreSQL database, a managed Redis cache, and a full Kubernetes cluster, all on Stackit, almost entirely automated with Terraform. I'll explain every piece from scratch, so even if you've never heard of Stackit, you'll walk away with a working mental model of how modern cloud infrastructure fits together.
The full source code is available at: GitHub Repository
First: What even is Stackit?
Before we touch any infrastructure, it's worth answering the obvious question.
Stackit is a European cloud platform from the SCHWARZ Group, the same holding company behind Lidl and Kaufland. It's positioned as a GDPR-compliant, EU-sovereign cloud alternative to AWS, Azure, and Google Cloud, with a strong focus on German and European businesses who care deeply about where their data lives and under which legal jurisdiction it operates.
For developers, it offers the same fundamental building blocks you'd find elsewhere:
- Virtual machines
- Managed Kubernetes (called SKE, Stackit Kubernetes Engine)
- Managed databases (PostgreSQL Flex, MySQL Flex)
- Managed caching (Redis)
- Object storage
- A Container Registry for Docker images
The API and tooling follow standard cloud patterns, Stackit has an official Terraform provider, a CLI, and a web portal at portal.stackit.cloud. If you've used GCP or DigitalOcean before, the concepts map directly.
Why use Stackit instead of AWS? For many European projects, the answer is regulation and data residency. Stackit's infrastructure runs exclusively in German data centers, which simplifies GDPR compliance dramatically. You don't need a Data Processing Agreement that spans multiple continents. For businesses operating under strict data governance requirements, that alone is often decisive.
For the rest of this post, treat Stackit as "a normal cloud provider with a clean Terraform provider." Everything we build here applies to the same concepts on any cloud, the specific resource names just differ.
What we're building
Our application is a URL shortener, a simple but complete full-stack system. You give it a long URL, it returns a short code. You visit the short code, it redirects you. Classic.
The reason this makes a great infrastructure example is that it genuinely needs all three layers most production apps require: a database for persistence, a cache for performance, and a web server for HTTP traffic. It's complex enough to be realistic, simple enough to follow without getting lost.
The architecture looks like this:
Browser (HTMX) ──► Node.js Backend ──► PostgreSQL (managed, persistent storage)
└─► Redis (managed, cache + click counter)
Here's the full stack we'll deploy:
| Component | Technology | Where it runs |
|---|---|---|
| Frontend | HTML + HTMX | Kubernetes pod |
| Backend API | Node.js + Express | Kubernetes pod |
| Database | PostgreSQL 16 | Stackit Managed PostgreSQL Flex |
| Cache | Redis 7 | Stackit Managed Redis |
| Container images | Docker | Stackit Container Registry |
| Orchestration | Kubernetes | Stackit Kubernetes Engine (SKE) |
| Infrastructure as Code | Terraform | Local machine / CI pipeline |
Everything except the Container Registry (which is still in beta and can't yet be created via Terraform) is provisioned with a single terraform apply. We'll cover how to work around the registry limitation using a specific Terraform trick.
The application itself (keep it simple)
The app is intentionally minimal, because this post is about infrastructure, not application development.
Frontend: An HTML page that uses HTMX, a lightweight library that lets you make AJAX requests directly from HTML attributes, without writing any JavaScript. It calls the backend API, displays the URL submission form, and shows the shortened results. No React, no build step, no node_modules. The Dockerfile for the frontend is tiny and the image builds in seconds.
Backend: A Node.js Express application with two core endpoints:
POST /api/shorten → accepts a long URL, stores it, returns a short code
GET /api/:code → looks up a short code, returns the original URL
The backend also needs to know its own public address so it can generate correct short URLs. This comes in through the PUBLIC_BASE_URL environment variable, for example, http://your-load-balancer-ip. You'll see this come up later when we configure our Kubernetes deployment.
The backend checks Redis first on every lookup. If the short code is cached, it returns immediately. If not, it queries PostgreSQL, then writes the result to Redis for next time. This is the classic cache-aside pattern.
Local development: The repo includes a docker-compose.yml that spins up everything locally. Notice how the environment variables in compose become your reference point for what the app expects in production:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: urlshortener
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
backend:
build: ./backend
environment:
PORT: 3000
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/urlshortener
REDIS_URL: redis://redis:6379
PUBLIC_BASE_URL: http://localhost:8080
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
frontend:
build: ./frontend
ports:
- "8080:80"
depends_on:
- backend
volumes:
postgres_data:
redis_data:
Run docker compose up and open http://localhost:8080. Everything works identically on your laptop as it will on Kubernetes, the only difference is where the environment variables point. That's the power of building with containers from the start.
Step 0: Setting up Stackit authentication
Before Terraform can create any Stackit resources, it needs to authenticate. Stackit uses Service Accounts for programmatic access, a dedicated identity for automation tools, completely separate from your personal user account.
Here's how to create one:
- Log into
portal.stackit.cloudand navigate to IAM → Service Accounts - Click Create Service Account, give it a name like
terraform-deployment - After creation, download the JSON key file (this is your private key, treat it like a password, never commit it to Git)
- Go to IAM → Access and click Grant Access
- Select your service account as the subject and assign the
project.ownerrole
The project.owner role gives Terraform full access to create and manage resources within your project. In a production setup, you'd go further and define a custom role scoped to exactly the permissions needed, but for learning, this is the correct starting point.
Once you have the key file, export its path as an environment variable:
export STACKIT_SERVICE_ACCOUNT_KEY_PATH=/path/to/your-sa-key.json
This is the variable the official Stackit Terraform provider looks for. Terraform will automatically pick it up from your environment when you run terraform apply. You never need to paste credentials into .tf files.
Step 1: The Terraform project structure
The repo organizes Terraform into the /terraform folder. The key files you'll find there:
terraform/
├── main.tf # Provider configuration
├── variables.tf # Input variable declarations
├── terraform.tfvars.example # Template copy this to terraform.tfvars
├── managed-services.tf # Redis + PostgreSQL resources
├── ske.tf # Kubernetes cluster
├── registry.tf # Docker image build + push (null provider)
└── kubernetes.tf # Namespaces, secrets, deployments, services
The terraform.tfvars.example file is a template containing all the variables you need to fill in. Copy it before you start:
cp terraform.tfvars.example terraform.tfvars
Then open terraform.tfvars and fill in your values. We'll go through each one as we reach it.
Step 2: Provider configuration
Every Terraform project starts with a main.tf that declares which providers you need. Providers are the translation layer between your Terraform code and the cloud APIs you want to talk to.
terraform {
required_providers {
stackit = {
source = "stackitcloud/stackit"
version = "~> 0.36"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.27"
}
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}
The stackit provider handles everything Stackit-specific: Redis, PostgreSQL, Kubernetes clusters. It reads STACKIT_SERVICE_ACCOUNT_KEY_PATH from your environment automatically, no explicit configuration block needed.
The kubernetes provider manages resources inside a Kubernetes cluster, deployments, namespaces, secrets, services. It's separate from the Stackit provider because Kubernetes has its own API server. We'll configure it with the credentials of our SKE cluster once it's provisioned.
The null provider lets you run arbitrary local shell commands as part of the Terraform graph. This is how we build and push Docker images, more on that shortly.
After declaring the providers, run:
terraform init
This downloads the provider binaries and initializes everything. You'll see confirmation that all three providers installed successfully.
Step 3: Provisioning Managed Redis
In our URL shortener, Redis caches short-code lookups so the backend doesn't hit PostgreSQL on every redirect request.
Here's the Stackit Terraform resource:
resource "stackit_redis_instance" "cache" {
project_id = var.project_id
name = "url-shortener-cache"
version = "7"
plan_name = "stackit-redis-1.2.10-single"
parameters = {
sgw_acl = "0.0.0.0/0"
}
}
Let's break down each field:
project_id: Every Stackit resource belongs to a project. You find your project ID in the portal under the top-left project dropdown → Project Settings.
version: Redis major version. We're using 7, the current stable release.
plan_name: This controls the instance size. stackit-redis-1.2.10-single is a single-node Redis instance (no replication) sized for small workloads. Stackit offers larger plans with more memory for heavier use.
sgw_acl: The Access Control List for the Security Gateway, essentially the firewall rules for who can connect to your Redis instance. 0.0.0.0/0 allows all IPs, which is only acceptable for a demo. In production, you'd replace this with your Kubernetes cluster's egress IP range.
After provisioning the instance, we also create credentials:
resource "stackit_redis_credentials" "redis_creds" {
project_id = var.project_id
instance_id = stackit_redis_instance.cache.instance_id
}
This generates a username and password for connecting to Redis. We'll use these outputs later when constructing the REDIS_URL for our Kubernetes pods.
Step 4: Provisioning Managed PostgreSQL
PostgreSQL is the permanent store. Every short code and its corresponding destination URL lives here, persisted across restarts and deployments.
Stackit offers PostgreSQL Flex, a fully managed PostgreSQL service where you control the CPU, RAM, storage class, and replica count, but Stackit handles the OS, replication, and backups.
resource "stackit_postgresflex_instance" "database" {
project_id = var.project_id
name = "url-shortener-db"
version = "16"
flavor = {
cpu = 1
ram = 4
}
storage = {
class = "premium-perf-2-stackit"
size = 5
}
replicas = 1
backup_schedule = "0 2 * * *"
acl = {
items = ["0.0.0.0/0"]
}
}
The fields worth understanding in detail:
version = "16": We match the same PostgreSQL major version as in our docker-compose.yml (postgres:16-alpine). This ensures no unexpected behavior differences between local dev and production.
flavor defines compute for the database server. cpu = 1, ram = 4 (in GB) is enough for this demo. Size this based on your actual query volume and dataset for production.
storage.class = "premium-perf-2-stackit": Stackit has multiple disk performance tiers, documented by IOPS and throughput. The perf-2 tier delivers around 1,000 IOPS and 100 MB/s throughput: solid for most small-to-medium workloads. You can find the full comparison in Stackit's documentation.
backup_schedule = "0 2 * * *": A cron expression for "every day at 2 AM." This single line gives you automated daily backups managed entirely by Stackit. On a self-managed PostgreSQL, this would require pg_dump scripts, rotation logic, and somewhere to store the backups. Here, it's one line.
replicas = 1: A single primary node. For a demo this is fine. In production, set this to 2 for a primary with one standby for automatic failover.
Once the instance exists, we create a database user and the database itself:
resource "stackit_postgresflex_user" "db_user" {
project_id = var.project_id
instance_id = stackit_postgresflex_instance.database.instance_id
username = var.db_username
roles = ["login"]
}
resource "stackit_postgresflex_database" "app_db" {
project_id = var.project_id
instance_id = stackit_postgresflex_instance.database.instance_id
name = var.db_name
owner = stackit_postgresflex_user.db_user.username
}
Note how app_db references db_user.username. Terraform builds a dependency graph from these reference, it automatically knows to create the user before the database. You never manage ordering manually.
Step 5: Creating the Kubernetes Cluster
We have a database and a cache, but nowhere to actually run the application. That's what the Kubernetes cluster solves.
Why Kubernetes and not just a VM?
A plain virtual machine would work for this demo, but it fails in important ways as soon as you want reliability or scalability. If the VM crashes, your app is down until you manually intervene. If you want to deploy a new version, you're SSH-ing in and restarting processes by hand, with downtime. Kubernetes handles all of this:
- If a pod crashes, Kubernetes restarts it automatically
- Deployments let you roll out new versions with zero downtime
- Services provide stable network addresses even as pods come and go
- The scheduler handles distributing workloads across nodes
The Stackit Kubernetes Engine (SKE) is managed Kubernetes, meaning Stackit runs the control plane for you (the API server, etcd, the scheduler). You only manage the worker nodes and the workloads running on them.
resource "stackit_ske_cluster" "app_cluster" {
project_id = var.project_id
name = "url-shortener-cluster"
kubernetes_version = "1.30"
node_pools = [
{
name = "default"
machine_type = "c1.2"
os_name = "flatcar"
minimum = 1
maximum = 2
}
]
}
kubernetes_version: You specify the minimum version and Stackit handles patch-level maintenance automatically.
node_pools: A node pool is a group of worker nodes (virtual machines) with identical specs. All your pods run on these nodes.
machine_type = "c1.2": A VM type in the Stackit catalog. The c prefix stands for compute-optimized. This roughly corresponds to 1 vCPU and 2 GB RAM, sufficient for this demo. You find the full machine type catalog in Stackit's documentation.
os_name = "flatcar": Flatcar Linux is a minimal, immutable OS designed specifically for running container workloads. It contains essentially nothing except the container runtime, no package manager, no general-purpose utilities. This dramatically reduces the attack surface compared to running containers on Ubuntu.
minimum = 1, maximum = 2: Autoscaling bounds. The cluster always keeps at least 1 node running, and can scale up to 2 under load.
Once the cluster is provisioned, we retrieve its kubeconfig, the credentials that allow other tools (and the Kubernetes Terraform provider) to talk to it:
resource "stackit_ske_kubeconfig" "kubeconfig" {
project_id = var.project_id
cluster_name = stackit_ske_cluster.app_cluster.name
}
We then use this kubeconfig to configure the Kubernetes provider. Terraform figures out the right execution order automatically, it knows the kubeconfig resource can only run after the cluster resource completes.
Step 6: The Container Registry and Docker images
Here's where things get interesting and where the repo takes a clever approach.
The Stackit Container Registry is currently in beta. There's no Terraform resource to create a registry project programmatically yet. So we do one manual step: open the Stackit portal, navigate to Developer Platform → Container Registry, create a new project (call it url-shortener), and note the registry URL: registry.onstackit.de.
Then log in to the registry. The credentials here are your service account email and service account token, the same service account you created in Step 0:
docker login registry.onstackit.de
# Username: your-service-account@...
# Password: your-service-account-token
From this point, everything is automated using the null provider. The null_resource can run arbitrary shell commands as part of the Terraform graph, with a triggers map that controls when those commands re-run.
First, we build the image URLs as locals so they're consistent everywhere:
locals {
backend_image = "registry.onstackit.de/${var.registry_project}/backend:${var.image_tag}"
frontend_image = "registry.onstackit.de/${var.registry_project}/frontend:${var.image_tag}"
}
Then we build and push both images:
resource "null_resource" "push_backend" {
triggers = {
image = local.backend_image
}
provisioner "local-exec" {
command = <<EOT
docker build -t ${local.backend_image} ../backend
docker push ${local.backend_image}
EOT
}
}
resource "null_resource" "push_frontend" {
triggers = {
image = local.frontend_image
}
provisioner "local-exec" {
command = <<EOT
docker build -t ${local.frontend_image} ../frontend
docker push ${local.frontend_image}
EOT
}
}
triggers is a map of values that tells Terraform when to re-run this resource. If image changes, for example because you changed var.image_tag, Terraform destroys and recreates the null_resource, which re-runs the build and push. If the value is unchanged, Terraform skips it entirely. This gives you a basic form of image build caching.
local-exec runs the command on the machine where Terraform is executing, your laptop, or your CI runner. That machine needs Docker installed and already authenticated to the registry via docker login.
The result: docker build and docker push happen as part of the same Terraform run that creates your infrastructure. One command, everything.
Step 7: Wiring the Kubernetes cluster resources
At this point in a terraform apply:
- ✅ Redis is running and has credentials
- ✅ PostgreSQL is running with a user and database
- ✅ Kubernetes cluster is ready with a kubeconfig
- ✅ Docker images are built and pushed
Now we configure what runs inside the cluster. All of this lives in kubernetes.tf.
The namespace
resource "kubernetes_namespace" "app" {
metadata {
name = "url-shortener"
}
depends_on = [stackit_ske_cluster.app_cluster]
}
A Kubernetes namespace is a logical partition inside a cluster. Resources in different namespaces are isolated from each other by default. The explicit depends_on ensures Terraform doesn't try to create the namespace before the cluster exists, because the Kubernetes provider is configured with the cluster's kubeconfig, Terraform can't infer this dependency automatically.
Connection strings as locals
Before creating Kubernetes resources, we build the connection strings from the outputs of our managed service resources:
locals {
database_url = "postgresql://${stackit_postgresflex_user.db_user.username}:${stackit_postgresflex_user.db_user.password}@${stackit_postgresflex_instance.database.host}:5432/${var.db_name}"
redis_url = "redis://:${stackit_redis_credentials.redis_creds.password}@${stackit_redis_instance.cache.host}:6379"
}
These connection strings are constructed entirely from Terraform resource attributes, host addresses, ports, and credentials all come directly from the managed service resources Terraform just created. You never manually look up a hostname or copy-paste a password. If Stackit changes the host of your database (for example after a failover event), you just re-run Terraform and the connection string updates automatically.
Kubernetes Secrets
Connection strings contain passwords. They must not be stored in plain text environment variables that get logged by container runtimes or exposed in kubectl describe pod output. Kubernetes Secrets are the native solution:
resource "kubernetes_secret" "app_secrets" {
metadata {
name = "app-secrets"
namespace = kubernetes_namespace.app.metadata[0].name
}
data = {
DATABASE_URL = local.database_url
REDIS_URL = local.redis_url
PUBLIC_BASE_URL = var.public_base_url
}
}
Note that PUBLIC_BASE_URL is also stored as a secret here. This is the URL the backend uses to generate short links, for example, http://your-load-balancer-ip. After the first terraform apply completes, you retrieve the load balancer IP, set public_base_url in your terraform.tfvars, and run terraform apply once more to update the secret.
The Deployments
A Kubernetes Deployment describes the desired state of a set of pods. Kubernetes continuously reconciles the actual state of the cluster with what you've declared, restarting pods that crash and spreading replicas across nodes.
The backend deployment:
resource "kubernetes_deployment" "backend" {
metadata {
name = "backend"
namespace = kubernetes_namespace.app.metadata[0].name
}
spec {
replicas = 1
selector {
match_labels = { app = "backend" }
}
template {
metadata {
labels = { app = "backend" }
}
spec {
container {
name = "backend"
image = local.backend_image
port {
container_port = 3000
}
env_from {
secret_ref {
name = kubernetes_secret.app_secrets.metadata[0].name
}
}
resources {
limits = {
cpu = "500m"
memory = "256Mi"
}
requests = {
cpu = "100m"
memory = "128Mi"
}
}
}
}
}
}
}
A few things worth explaining:
env_from with secret_ref: This injects every key from app_secrets as an environment variable in the container. The backend reads process.env.DATABASE_URL, process.env.REDIS_URL, and process.env.PUBLIC_BASE_URL, the same variable names as in the local docker-compose.yml. Your application code doesn't know or care whether it's running locally or on Kubernetes.
resources.requests vs resources.limits: A critical Kubernetes concept that beginners almost always skip. requests is what the scheduler uses when deciding which node to place the pod on, it reserves that amount. limits is the hard ceiling the container cannot exceed. cpu = "500m" means 500 millicores, or half a CPU core. Without limits, a runaway process can starve other pods on the same node.
selector and match_labels: This is how the Service knows which pods to route traffic to. The label app = "backend" is set on the pods and referenced by the Service selector. Add more replicas and they automatically receive traffic through the same Service.
The frontend deployment is nearly identical but uses local.frontend_image and container port 80.
Services: the networking layer
A Kubernetes Service provides a stable network endpoint in front of a set of pods. Because pods are ephemeral, they're replaced, rescheduled, and scaled constantly, you never point anything directly at a pod's IP. The Service gives you a stable address that always routes to healthy pods.
We use two different Service types, and the difference is deliberate:
Backend — ClusterIP:
resource "kubernetes_service" "backend" {
metadata {
name = "backend"
namespace = kubernetes_namespace.app.metadata[0].name
}
spec {
selector = { app = "backend" }
type = "ClusterIP"
port {
port = 80
target_port = 3000
}
}
}
ClusterIP assigns an IP address reachable only from within the cluster. The backend is not accessible from the internet. Only the frontend pods, running inside the same cluster, can reach it. This is a genuine security boundary: your Node.js API is not exposed to the outside world, even without any additional firewall rules.
Frontend — LoadBalancer:
resource "kubernetes_service" "frontend" {
metadata {
name = "frontend"
namespace = kubernetes_namespace.app.metadata[0].name
}
spec {
selector = { app = "frontend" }
type = "LoadBalancer"
port {
port = 80
target_port = 80
}
}
}
LoadBalancer tells Kubernetes to provision a real cloud load balancer. On Stackit, this triggers the SKE cloud controller to create a Stackit Load Balancer with a public IP address, automatically, without any additional Terraform resources. You just declare type = "LoadBalancer" and Stackit handles the provisioning.
Step 8: Running the deployment
With everything configured, deploying is three commands from inside the terraform/ folder:
# Step 1: Log in to the container registry first (one-time per machine)
docker login registry.onstackit.de
# Step 2: Initialize Terraform (downloads providers, sets up state)
terraform init
# Step 3: Preview what will be created
terraform plan
# Step 4: Create everything
terraform apply
The first terraform apply will take roughly 10–15 minutes. PostgreSQL and Redis provisioning takes the longest, managed databases need a few minutes to fully initialize. Kubernetes cluster creation typically takes 5–8 minutes. Terraform runs what it can in parallel, so the overall wall-clock time is shorter than the sum of individual steps.
While it runs, you'll see a live summary:
stackit_redis_instance.cache: Creating...
stackit_postgresflex_instance.database: Creating...
stackit_ske_cluster.app_cluster: Creating...
...
stackit_redis_instance.cache: Creation complete after 3m12s
...
Apply complete! Resources: 17 added, 0 changed, 0 destroyed.
Getting the public URL
After apply completes, retrieve the LoadBalancer IP:
kubectl get svc frontend -n url-shortener -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
This gives you the public IP. Open it in a browser, your URL shortener is live.
But there's one more step: the backend needs to know this IP so it can generate correct short URLs. Update public_base_url in your terraform.tfvars:
public_base_url = "http://<your-loadbalancer-ip>"
Then run terraform apply once more. This updates the Kubernetes Secret with the correct PUBLIC_BASE_URL and triggers a rolling restart of the backend pods. The whole thing takes under a minute.
The variables you need to fill in
All configuration lives in terraform.tfvars. Copy the example file first:
cp terraform.tfvars.example terraform.tfvars
Then fill in these values:
| Variable | Where to find it |
|---|---|
project_id |
Portal → top-left dropdown → Project Settings |
registry_project |
The name you gave your Container Registry project |
image_tag |
Any string, e.g. latest or a git commit SHA |
db_username |
Choose a username, e.g. app
|
db_password |
Choose a secure password |
db_name |
Choose a database name, e.g. urlshortener
|
public_base_url |
Fill in after first apply using the LoadBalancer IP |
For the public_base_url, a two-step apply is expected: first apply without it (or with a placeholder), retrieve the LoadBalancer IP, set it, then apply again.
Tearing it all down
terraform destroy
This removes every resource Terraform created, the Redis instance, PostgreSQL instance, Kubernetes cluster, all workloads, and the Load Balancer. The Container Registry project (created manually in the portal) must be deleted manually.
What to harden before going to production
This setup is correct but not yet production-hardened. Here are the gaps to close:
Firewall rules: Both Redis and PostgreSQL allow connections from 0.0.0.0/0. In production, replace this with the egress IP of your SKE cluster's node pool. You can retrieve the node IPs from the SKE cluster resource outputs and pass them directly into sgw_acl and the PostgreSQL ACL.
Database replicas: replicas = 1 means no automatic failover. Set replicas = 2 for a primary with one standby, Stackit Postgres Flex DB handles the synchronous replication and automatic promotion automatically.
Image tagging: Using latest means you can't reliably roll back a deployment. Tag images with the git commit SHA instead:
image_tag = $(git rev-parse --short HEAD)
Secrets management: Kubernetes Secrets are base64-encoded but not encrypted at rest by default. For production, enable etcd encryption at rest in your SKE cluster configuration, or use an external secrets manager.
CI/CD pipeline: Manual terraform apply from a laptop works for learning. In a team, wrap this in a GitHub Actions workflow: run terraform plan on every pull request so reviewers can see the infrastructure diff, and terraform apply automatically on merge to main.
Key takeaways
Managed services eliminate entire categories of work. A single backup_schedule line in Terraform gives you nightly PostgreSQL backups. One ACL parameter gives you basic network security. The operational value isn't just scaling, it's the maintenance burden that simply doesn't exist.
Terraform locals are your wiring layer. Build connection strings and configuration values from resource outputs, never from manually copied hostnames or passwords. If the infrastructure changes, the wiring updates automatically on the next terraform apply.
The null provider fills real gaps. When a cloud provider's API doesn't have Terraform support yet, like the Stackit Container Registry in beta, null_resource with local-exec lets you automate the step anyway, keeping the entire deployment in one workflow.
ClusterIP vs LoadBalancer is a real security decision. The backend on ClusterIP means the outside world has no path to your API, even without explicit firewall rules. The only public surface is the LoadBalancer in front of the frontend.
The order of operations is a dependency graph, not a script. Terraform figures out what to create first based on references between resources. You describe what you want, not in what order to build it. That's what makes Infrastructure as Code fundamentally different from a deployment script.
Found a step unclear or want a follow-up on TLS with cert-manager, proper production firewall rules, or building the GitHub Actions pipeline? Drop a comment or reply, I read every one.
























