You have a tested, Dockerized Ktor application. The next step is automating everything. When you push code, tests should run automatically. When tests pass on the main branch, the application should deploy automatically.

In this tutorial, you will set up a GitHub Actions CI/CD pipeline. You will also add Prometheus metrics for production monitoring.

What is CI/CD?

CI (Continuous Integration) means running tests automatically when you push code. If tests fail, you know immediately — not after deploying to production.

CD (Continuous Deployment) means deploying automatically when tests pass. No manual steps, no forgetting to deploy, no “it works on my machine” problems.

Together, CI/CD gives you confidence that every change is tested and deployed safely.

The CI/CD Pipeline

Here is what happens when you push code:

Push → Build → Test → Build Docker Image → Deploy

Every push triggers the pipeline. Tests run first. If they fail, the pipeline stops. If they pass on the main branch, the Docker image is built and tested.

GitHub Actions Workflow

Create .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main, "tutorial-*"]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Cache Gradle dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}
          restore-keys: ${{ runner.os }}-gradle-

      - name: Run tests
        run: ./gradlew test

      - name: Build
        run: ./gradlew build

      - name: Build fat JAR
        run: ./gradlew buildFatJar

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: build/reports/tests/

  docker:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t ktor-tutorial .

      - name: Test Docker image
        run: |
          docker run -d --name ktor-test -p 8080:8080 ktor-tutorial
          sleep 5
          curl -f http://localhost:8080/health || exit 1
          docker stop ktor-test

Step by Step

Trigger:

on:
  push:
    branches: [main, "tutorial-*"]
  pull_request:
    branches: [main]

The pipeline runs on pushes to main and tutorial branches, and on pull requests to main.

Gradle caching:

- name: Cache Gradle dependencies
  uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}

Without caching, every build downloads all dependencies. Caching saves 1-2 minutes per run. The cache key is based on your build.gradle.kts — when dependencies change, the cache updates.

Test results:

- name: Upload test results
  if: always()
  uses: actions/upload-artifact@v4

if: always() uploads test results even when tests fail. You can download the HTML report from the GitHub Actions artifacts tab.

Docker job:

docker:
  needs: build
  if: github.ref == 'refs/heads/main'

The Docker job runs only on the main branch and only after tests pass. It builds the Docker image and runs a smoke test against the /health endpoint.

Secrets Management

Never put secrets in your workflow file. Use GitHub Actions secrets:

  1. Go to your repository on GitHub
  2. Settings → Secrets and variables → Actions
  3. Add secrets: JWT_SECRET, DB_PASSWORD, etc.

Access them in workflows:

env:
  JWT_SECRET: ${{ secrets.JWT_SECRET }}

For your Ktor application, the secrets you need:

SecretDescription
JWT_SECRETJWT signing key
DB_PASSWORDDatabase password
GOOGLE_CLIENT_IDOAuth client ID
GOOGLE_CLIENT_SECRETOAuth client secret
DEPLOY_SSH_KEYSSH key for deployment

Prometheus Metrics

In production, you need to know how your application performs. Prometheus collects metrics like request count, response times, and error rates.

Dependencies

dependencies {
    implementation("io.ktor:ktor-server-metrics-micrometer:$ktorVersion")
    implementation("io.micrometer:micrometer-registry-prometheus:1.14.5")
}

Configuration

package com.kemalcodes.plugins

import io.ktor.server.application.*
import io.ktor.server.metrics.micrometer.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.micrometer.prometheusmetrics.PrometheusConfig
import io.micrometer.prometheusmetrics.PrometheusMeterRegistry

// Configure Micrometer metrics with Prometheus
fun Application.configureMonitoring() {
    val prometheusMeterRegistry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT)

    install(MicrometerMetrics) {
        registry = prometheusMeterRegistry
    }

    routing {
        // Prometheus metrics endpoint
        get("/metrics") {
            call.respondText(prometheusMeterRegistry.scrape())
        }
    }
}

Install it in your application module:

fun Application.module() {
    configureDI()
    configureDatabase()
    configureSerialization()
    configureAuthentication()
    configureCors()
    configureRateLimit()
    configureSecurityHeaders()
    configureWebSockets()
    configureStatusPages()
    configureMonitoring()  // Add metrics
    configureOpenApi()
    configureRouting()
}

What Metrics Are Collected

Visit http://localhost:8080/metrics to see the raw metrics:

# HTTP request metrics
ktor_http_server_requests_seconds_count{method="GET",route="/api/notes",status="200"} 42
ktor_http_server_requests_seconds_sum{method="GET",route="/api/notes",status="200"} 1.234

# JVM metrics
jvm_memory_used_bytes{area="heap"} 67108864
jvm_threads_live_threads 15

Key metrics to watch:

MetricWhat It Tells You
ktor_http_server_requests_seconds_countTotal request count per endpoint
ktor_http_server_requests_seconds_sumTotal time spent per endpoint
jvm_memory_used_bytesMemory usage
jvm_threads_live_threadsActive threads

Prometheus + Grafana

For visualization, set up Prometheus to scrape your /metrics endpoint and connect Grafana for dashboards. This is a common production monitoring stack:

Ktor App → /metrics → Prometheus (collects) → Grafana (visualizes)

Setting up Prometheus and Grafana is beyond this tutorial’s scope. The key takeaway: your application exposes metrics. Any monitoring tool that supports Prometheus format can consume them.

What Metrics Are Collected

The Micrometer plugin automatically collects HTTP metrics for every request:

  • Request count per route and status code
  • Request duration (percentiles: p50, p95, p99)
  • Active requests count
  • JVM memory usage (heap, non-heap)
  • JVM threads count
  • Garbage collection frequency and pause time

You do not need to add any code to your routes. The plugin intercepts every request and records metrics automatically.

Custom Metrics

For application-specific metrics, create custom counters and gauges:

val noteCreatedCounter = prometheusMeterRegistry.counter("notes.created")

// In your route
post("/api/notes") {
    val note = noteService.createNote(request)
    noteCreatedCounter.increment()
    call.respond(HttpStatusCode.Created, note)
}

Custom metrics help you track business events — notes created, users registered, authentication failures.

Structured Logging for Production

In Tutorial #3, we set up basic logging with Logback. For production, use structured JSON logging:

<!-- src/main/resources/logback.xml -->
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

In production, structured logs (JSON format) are easier to search and filter in log aggregation tools like Loki, Elasticsearch, or CloudWatch.

Health Checks and Readiness

We already have a basic health endpoint:

get("/health") {
    call.respondText("OK")
}

For production, consider a deeper health check that verifies database connectivity:

get("/health") {
    try {
        // Verify database connection
        newSuspendedTransaction {
            exec("SELECT 1")
        }
        call.respondText("OK")
    } catch (e: Exception) {
        call.respondText("UNHEALTHY", status = HttpStatusCode.ServiceUnavailable)
    }
}

Docker Compose and Kubernetes use this endpoint to detect and restart unhealthy containers.

Database Migrations in CI

Your Flyway migrations from Tutorial #10 run automatically when the application starts. In CI, this happens against the test H2 database:

Application starts → Flyway runs migrations → Tests execute

For production deployment, migrations run against the real PostgreSQL database when the new container starts. If a migration fails, the application does not start, and the health check reports unhealthy.

Testing Migrations

Add a test that verifies all migrations apply cleanly:

@Test
fun `application starts with all migrations`() = testApplication {
    application { module() }

    val response = client.get("/health")
    assertEquals(HttpStatusCode.OK, response.status)
}

This simple test catches broken migrations. If any SQL migration has a syntax error, the application fails to start, and this test fails.

Deployment Options

Option 1: VPS (Hetzner, DigitalOcean)

Copy the Docker image to a server and run it with Docker Compose:

# On your server
docker compose pull
docker compose up -d

Pros: Full control, predictable costs Cons: You manage the server, updates, and security

Option 2: Railway

Railway deploys Docker containers automatically:

  1. Connect your GitHub repository
  2. Railway detects the Dockerfile
  3. Every push to main triggers a deploy

Pros: Zero server management, automatic deploys Cons: Higher cost at scale

Option 3: Fly.io

Fly.io runs containers on edge servers worldwide:

fly launch
fly deploy

Pros: Global distribution, simple CLI Cons: Learning curve for configuration

For most projects, start with Railway for simplicity. Move to a VPS when you need more control or want to reduce costs.

Reverse Proxy with Nginx

In production, put Nginx in front of your Ktor application:

Client → Nginx (port 443, HTTPS) → Ktor (port 8080, HTTP)

Nginx handles:

  • TLS termination — HTTPS encryption
  • Static file serving — Offloads static assets from Ktor
  • Load balancing — Distributes traffic across multiple Ktor instances

Your Ktor application does not need to configure HTTPS. Nginx handles it with Let’s Encrypt certificates.

Branch Protection Rules

Set up branch protection on GitHub to enforce CI:

  1. Go to Settings -> Branches -> Add rule
  2. Branch name pattern: main
  3. Enable “Require status checks to pass before merging”
  4. Select the “build” job as required

Now nobody can push directly to main if tests fail. All changes go through pull requests, and the CI pipeline must pass before merging.

Workflow for New Features

1. Create feature branch from main
2. Write code and tests
3. Push to GitHub → CI runs tests
4. Create pull request
5. CI runs on PR → tests must pass
6. Merge to main → Docker build + deploy

This workflow catches bugs before they reach production. Every change is tested automatically.

Environment Configuration Per Stage

In practice, you have multiple environments:

EnvironmentDatabasePurpose
LocalH2 (in-memory)Development
CIH2 (in-memory)Automated testing
StagingPostgreSQLPre-production testing
ProductionPostgreSQLLive users

Your Ktor application handles this through environment variables. The same Docker image runs in staging and production — only the environment variables change.

# Staging
environment:
  - DB_URL=jdbc:postgresql://staging-db:5432/ktor_tutorial
  - JWT_SECRET=staging-secret

# Production
environment:
  - DB_URL=jdbc:postgresql://prod-db:5432/ktor_tutorial
  - JWT_SECRET=production-secret-from-vault

Never use the same secrets in staging and production. If staging is compromised, production stays safe.

Complete Deployment Checklist

Before deploying to production, verify:

  • All tests pass (./gradlew test)
  • Docker image builds (docker build -t app .)
  • Health check works (curl http://localhost:8080/health)
  • Environment variables are set (JWT_SECRET, DB_PASSWORD, etc.)
  • Secrets are not in source code
  • Database migrations run on startup (Flyway)
  • CORS is configured for your frontend domain
  • Rate limiting is enabled on auth endpoints
  • Logging is configured for production
  • Metrics endpoint is accessible (/metrics)
  • Non-root user in Docker
  • .dockerignore excludes unnecessary files

Common Mistakes

  1. Hardcoding secrets in CI config — Use GitHub Actions secrets, not plain text in workflow files.

  2. Not running tests before deploy — The CI pipeline must run tests first. A failed deployment takes down your production server.

  3. Skipping health checks — Without health checks, broken containers stay running and serve errors.

  4. Not caching Gradle dependencies — Every CI run downloads all dependencies from scratch. Caching saves minutes per build.

  5. Deploying without database migrations — Your code expects new tables or columns, but the database has the old schema. Run Flyway migrations on startup.

Source Code

You can find the source code for this tutorial on GitHub:

github.com/kemalcodes/ktor-tutorial — Branch: tutorial-21-cicd

What’s Next?

Your application has CI/CD, monitoring, and is ready for production. In the final tutorial, you will build a Full-Stack Kotlin App — connecting your Ktor backend to a KMP client with shared data models.