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:
- Go to your repository on GitHub
- Settings → Secrets and variables → Actions
- 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:
| Secret | Description |
|---|---|
JWT_SECRET | JWT signing key |
DB_PASSWORD | Database password |
GOOGLE_CLIENT_ID | OAuth client ID |
GOOGLE_CLIENT_SECRET | OAuth client secret |
DEPLOY_SSH_KEY | SSH 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:
| Metric | What It Tells You |
|---|---|
ktor_http_server_requests_seconds_count | Total request count per endpoint |
ktor_http_server_requests_seconds_sum | Total time spent per endpoint |
jvm_memory_used_bytes | Memory usage |
jvm_threads_live_threads | Active 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:
- Connect your GitHub repository
- Railway detects the Dockerfile
- 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:
- Go to Settings -> Branches -> Add rule
- Branch name pattern:
main - Enable “Require status checks to pass before merging”
- 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:
| Environment | Database | Purpose |
|---|---|---|
| Local | H2 (in-memory) | Development |
| CI | H2 (in-memory) | Automated testing |
| Staging | PostgreSQL | Pre-production testing |
| Production | PostgreSQL | Live 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
-
.dockerignoreexcludes unnecessary files
Common Mistakes
Hardcoding secrets in CI config — Use GitHub Actions secrets, not plain text in workflow files.
Not running tests before deploy — The CI pipeline must run tests first. A failed deployment takes down your production server.
Skipping health checks — Without health checks, broken containers stay running and serve errors.
Not caching Gradle dependencies — Every CI run downloads all dependencies from scratch. Caching saves minutes per build.
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.
Related Articles
- Ktor Tutorial #20: Dockerizing Ktor — Docker setup this CI/CD builds on
- Ktor Tutorial #19: Testing — Tests that run in CI
- Ktor Tutorial #10: Flyway Migrations — Database migrations for production
- Git Cheat Sheet — Git commands reference