惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
P
Proofpoint News Feed
Spread Privacy
Spread Privacy
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Security Latest
Security Latest
P
Privacy & Cybersecurity Law Blog
AWS News Blog
AWS News Blog
W
WeLiveSecurity
I
Intezer
Attack and Defense Labs
Attack and Defense Labs
Google Online Security Blog
Google Online Security Blog
S
Schneier on Security
N
News and Events Feed by Topic
T
Threat Research - Cisco Blogs
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Hacker News: Ask HN
Hacker News: Ask HN
Know Your Adversary
Know Your Adversary
N
News and Events Feed by Topic
K
Kaspersky official blog
NISL@THU
NISL@THU
Recent Commits to openclaw:main
Recent Commits to openclaw:main
M
Microsoft Research Blog - Microsoft Research
S
Secure Thoughts
罗磊的独立博客
WordPress大学
WordPress大学
酷 壳 – CoolShell
酷 壳 – CoolShell
Project Zero
Project Zero
Latest news
Latest news
Vercel News
Vercel News
阮一峰的网络日志
阮一峰的网络日志
The Hacker News
The Hacker News
L
LangChain Blog
PCI Perspectives
PCI Perspectives
博客园 - Franky
P
Palo Alto Networks Blog
A
Arctic Wolf
Hugging Face - Blog
Hugging Face - Blog
量子位
L
LINUX DO - 热门话题
人人都是产品经理
人人都是产品经理
T
Tor Project blog
博客园 - 叶小钗
C
CERT Recently Published Vulnerability Notes
李成银的技术随笔
美团技术团队
Apple Machine Learning Research
Apple Machine Learning Research
Application and Cybersecurity Blog
Application and Cybersecurity Blog
博客园 - 三生石上(FineUI控件)
Scott Helme
Scott Helme
雷峰网
雷峰网

DEV Community

Class and Pseudo Class Git & GitLab Basics 고객은 우리를 사기꾼으로 봤다: 아무도 믿지 않는 신사업을 단 둘이서 검증한 3개월 Cron Not Working on Mac? How to Fix the macOS Sleep Trap with launchd Cache Everything: Advanced Caching Strategies in Vue 3 & Nuxt 4 Slopsquatting & Remote Prompts: Why I Built a 38,000 Ticker Engine with Zero NPM Dependencies 05/20: TCP/IP vs OSI Model: The Ultimate Comparison My New Adventures in IT # Mitigating Market Inefficiency in eSports: A Stochastic Approach to EA Sports FC25 Modeling Don't let a billion RAG docs drown your 25-result pipeline Experienced devs are slower with AI tools. Nobody wants to admit it. I built an MCP-native OSINT framework that lets AI agents investigate from your terminal AWS Nitro Enclaves vs Intel TDX: Why Attestation Root Matters for Regulated Workloads Vibe Coding: Revolution or Risk in Software Development? - SmarterArticles S1E6 JSON Schema Explained: Validate Your API Data Before It Breaks Production Harness Tells Your Agent What to Do. GUI Agents Let It Actually Do It. Is AI actually replacing developers? Customizing Docker Images: Write Your First Dockerfile (2026) €40 n8n vs 28% weekly Anthropic quota. Which /goal layer should you actually run? Reviving glyph-v8: From a Forgotten Prototype to STRIDE - a Field-Aware Integer Coder 04/20: Data Encapsulation: How a Message Becomes Bits on the Wire Hướng Dẫn Thiết Lập Reasoning Proxy DeepSeek V4-Pro với Cursor (2026) Sofi Log #012: Agentic GDP — Solana Pay.sh & x402 Protocol Spec Input Types, Attributes, Self-Closing Tags, Hover Effect Absolute vs Relative Paths File Types (Regular, Directory, Link, Device, Socket, Pipe) From Arduino IDE to AVR GCC | AVR Bare Metal #1 Using Bitcoin as collateral without wrapping it: the design of a BTC collateral vault Unreal Engine 5 Skill System Architecture using GAS and GameplayTags 5 Things I Wish I Knew Before Building with Hermes Agent Thoughts on Codingame 2026 Spring challenge OUT WITH THE OLD IN WITH THE NEW Why are simple 1099 tax calculators online so horribly bloated? So I built my own "Why You're Not Getting Callbacks (It's Not Your Skills)" # How I Built a Retail Demand Forecasting App with Python and Streamlit Why We Deliberately Crush Lithium Batteries (UN38.3 Crush Testing Explained) Command History & Completion The Three-Body Problem: AI Code, Supply Chain Attacks, and the Talent Exodus 로컬 LLM 셋업 가이드 (v27) Building Better .NET Worker Services with Cursor Rules Generate Professional PDF Invoices via REST API — JSON In, PDF Out Redis: Big Keys Destroem o Desempenho Compartilhado Agentic AI for Cybersecurity: Autonomous Threat Detection and Response How to Automate Android Without Appium Cron vs systemd daemon: which one for Node.js? Designing XSLT transforms with parameters and multiple inputs I Downloaded Gemma4:e2b On My Macbook in 2 steps Building an Autonomous SRE Agent: From Raw Telemetry to Safe, AI-Driven Remediation The EU AI Act in 2026: Reading the Law After the Omnibus I had zero coding knowledge. Here is "RetroTube", a 2010 YouTube sandbox prototype I built using AI! How to Validate Environment Variables in TypeScript (and Why You Should) I Built a CLI Tool That Writes Better Git Commits Than I Do Transfer Fees, Metadata, and Soulbound Tokens: My First Real Token Experiments on Solana Stop Using Fetch() in React: A Better Way To Call Your Backend Creando un Tetris con JavaScript VI: Complicando el juego. DeepSeek's API Price Cut Changed My Claude Code and ChatGPT Math [Boost] Perl 🐪 Weekly #774 - Perl is too HOT How to Track AI Usage Without Losing Revenue (Complete Guide) 77 Rules Later: What Graduating Our First Stack Actually Looked Like RAG 시스템 실전 구축 (v26) When Premature Scaling Leads to Operator Burnout Multi-Repo Microservice Changes Are a Coordination Problem. I Solved It With AI Agent Teams. The Next Frontier: How Multi-Agent Systems are Redefining Productivity The Kimwolf Bust Just Outed Android Webcams as Botnet Fodder — Here's the Question Every Repurposed-Phone Camera Setup Has to Answer I'm an autonomous AI agent. I shipped 18 fixes to myself in one session. Building a Secure Future with Zero Trust Security Architecture Asynchronous Functions in Dart How I migrated magic-link login from Resend to AWS SES + Lambda five days before launch Edge Computing He creado una empresa ficticia IT/OT para poder encontrar sus vulnerabilidades y reforzar su seguridad en sus activos críticos Why I Built @editora/react I built a tiny UGC script generator because hooks are the hardest part The Phone Is Becoming the New Terminal Why Most AI Music Tools Feel Wrong to Developers Goroutines vs. Promises: Why Go and JavaScript Look at Concurrency Completely Differently How I Use Antigravity 2.0 to Navigate Open-Source Codebases and Make Better Technical Decisions Understanding Basic HTML & CSS Concepts for Beginners Go Error Handling: Annoying or Awesome? Your To-Do List Doesn't Know You — So I Gave Mine Three Brains Shell Basics (Bash, Zsh, Sh) Free MongoDB GUI Tool for Developers, Students, and Teams Designing High-Performance Blockchain Indexers Choosing Models for an Agentic Chat App on Amazon Bedrock How Smart Growth Teams Automate Their Marketing Stack in 2026 (Without Hiring More People) What I Learned About Memory-Augmented AI Agents Seven Docker Tips Every Engineer Should Know (from Docker Captains) Welcome to the Fast-Food Era of Testing: Over-Weight by Tests How to use Claude in vscode? Prompt Engineering for Automated Evaluation: Making LLMs the Judge in AI Builder Solutions Full Stack Projects Are Not Enough Anymore Virtualization & Cloud Basics Orakle: Turning Raw Blockchain Data into Intelligence with Gemma 4 Building an Autoposting Pipeline with Hermes Agent: Why Waterfall Beats Parallel, and the Edge Cases Nobody Talks About OpenShift Virtualization Migration Advisor — Local-First, Powered by Gemma 4 26B MoE WebMCP is coming — so I’m building webmcp.js I Disappeared for 4 Months After Launch - Here's What Brought Me Back Jira Is Turing-Complete (And You've Been Coding in It) NyayAI: Building an AI Legal Assistant for 1.4 Billion People — A Technical Deep Dive E-commerce Order Automation: Stripe + Invoice + Shipping Workflow
Deploy a Node.js App to STACKIT Kubernetes Engine With Managed Redis & PostgreSQL
Florian Lenz · 2026-05-25 · via DEV Community

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)

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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:

Enter fullscreen mode Exit fullscreen mode

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:

  1. Log into portal.stackit.cloud and navigate to IAM → Service Accounts
  2. Click Create Service Account, give it a name like terraform-deployment
  3. After creation, download the JSON key file (this is your private key, treat it like a password, never commit it to Git)
  4. Go to IAM → Access and click Grant Access
  5. Select your service account as the subject and assign the project.owner role

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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"
  }
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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"]
  }
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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
    }
  ]
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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}"
}

Enter fullscreen mode Exit fullscreen mode

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
  }
}

Enter fullscreen mode Exit fullscreen mode

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]
}

Enter fullscreen mode Exit fullscreen mode

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"
}

Enter fullscreen mode Exit fullscreen mode

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
  }
}

Enter fullscreen mode Exit fullscreen mode

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"
            }
          }
        }
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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.

Enter fullscreen mode Exit fullscreen mode

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}'

Enter fullscreen mode Exit fullscreen mode

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>"

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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.