Your Ktor application runs on your machine. But “it works on my machine” is not a deployment strategy. Docker packages your application with everything it needs — the JVM, dependencies, and configuration — into a single image that runs anywhere.

In this tutorial, you will create a Dockerfile for your Ktor application. You will use multi-stage builds for smaller images, configure Docker Compose with PostgreSQL, and tune the JVM for containers.

Prerequisites

You need Docker installed on your machine. Download it from docker.com. Verify the installation:

docker --version
# Docker version 27.x.x

docker compose version
# Docker Compose version v2.x.x

If both commands print a version, you are ready.

The Fat JAR

Before Docker, you need a way to package your Ktor application into a single file. A fat JAR (also called an uber JAR) bundles your code and all dependencies into one .jar file.

Add the fat JAR task to build.gradle.kts:

// Fat JAR task for Docker deployment
tasks.register<Jar>("buildFatJar") {
    archiveClassifier.set("all")
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
    manifest {
        attributes["Main-Class"] = "com.kemalcodes.ApplicationKt"
    }
    from(configurations.runtimeClasspath.get().map {
        if (it.isDirectory) it else zipTree(it)
    })
    with(tasks.jar.get())
}

Build it:

./gradlew buildFatJar

This creates build/libs/ktor-tutorial-0.0.1-all.jar. You can run it directly:

java -jar build/libs/ktor-tutorial-0.0.1-all.jar

The server starts on port 8080. This is what Docker will run inside the container.

The Dockerfile

Create a Dockerfile in your project root:

# Stage 1: Build the application
FROM gradle:8.12-jdk21 AS build
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts gradle.properties ./
COPY src ./src
RUN gradle buildFatJar --no-daemon

# Stage 2: Run the application
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

# Create a non-root user for security
RUN addgroup -S ktor && adduser -S ktor -G ktor

# Copy the fat JAR from the build stage
COPY --from=build /app/build/libs/*-all.jar app.jar

# Create uploads directory
RUN mkdir -p /app/uploads && chown -R ktor:ktor /app

USER ktor

# JVM container settings
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Multi-Stage Build Explained

The Dockerfile has two stages:

Stage 1 (build): Uses the full Gradle image with JDK 21. It copies source code, downloads dependencies, and builds the fat JAR. This stage produces a ~500 MB image.

Stage 2 (run): Uses a minimal Alpine Linux image with just JRE 21. It copies only the fat JAR from stage 1. The final image is ~150 MB instead of 500 MB.

Why does image size matter? Smaller images deploy faster, use less disk, and have fewer security vulnerabilities (fewer packages = fewer CVEs).

Security: Non-Root User

RUN addgroup -S ktor && adduser -S ktor -G ktor
USER ktor

Never run containers as root. If an attacker exploits your application, a non-root user limits the damage.

JVM Container Settings

ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
  • -XX:+UseContainerSupport — Tells the JVM to respect Docker memory limits instead of using the host’s total memory
  • -XX:MaxRAMPercentage=75.0 — Uses 75% of the container’s memory limit for the heap. The remaining 25% is for the JVM itself (metaspace, thread stacks, native memory)

Without these flags, the JVM might try to use more memory than the container allows, causing OOM kills.

Health Check

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

Docker checks GET /health every 30 seconds. If the check fails 3 times in a row, Docker marks the container as unhealthy. Orchestrators like Docker Compose and Kubernetes use this to restart unhealthy containers.

We added the /health endpoint back in Tutorial #4:

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

The .dockerignore File

Create a .dockerignore to exclude unnecessary files from the Docker build context:

.gradle
build
.idea
*.iml
.git
uploads
docs

Without .dockerignore, Docker copies your entire project directory into the build context — including the .git folder, IDE files, and previous build artifacts. This slows down the build and wastes space.

Building the Docker Image

# Build the image
docker build -t ktor-tutorial .

# Run the container
docker run -p 8080:8080 ktor-tutorial

# Test it
curl http://localhost:8080/health
# Output: OK

The -t ktor-tutorial flag gives the image a name. The -p 8080:8080 flag maps port 8080 on your machine to port 8080 inside the container.

Docker Compose

Your application needs a database. Docker Compose runs multiple containers together:

services:
  # Ktor application
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DB_URL=jdbc:postgresql://db:5432/ktor_tutorial
      - DB_USER=ktor
      - DB_PASSWORD=ktor_password
      - DB_DRIVER=org.postgresql.Driver
      - JWT_SECRET=change-this-in-production
      - GOOGLE_CLIENT_ID=your-google-client-id
      - GOOGLE_CLIENT_SECRET=your-google-client-secret
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  # PostgreSQL database
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: ktor_tutorial
      POSTGRES_USER: ktor
      POSTGRES_PASSWORD: ktor_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ktor -d ktor_tutorial"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  postgres_data:

Key Parts

depends_on with health check:

depends_on:
  db:
    condition: service_healthy

The app container waits until PostgreSQL is healthy before starting. Without this, Ktor might try to connect before the database is ready.

Volume for data persistence:

volumes:
  postgres_data:

Without a volume, deleting the database container deletes all data. The named volume postgres_data keeps data between container restarts.

Environment variables:

environment:
  - DB_URL=jdbc:postgresql://db:5432/ktor_tutorial

db is the hostname of the PostgreSQL container. Docker Compose creates a network where containers can reach each other by service name.

Running with Docker Compose

# Start both containers
docker compose up -d

# Check the logs
docker compose logs -f app

# Test the API
curl http://localhost:8080/health
curl http://localhost:8080/api/notes

# Stop everything
docker compose down

# Stop and delete data
docker compose down -v

The -d flag runs containers in the background. docker compose logs -f shows live logs. docker compose down -v removes volumes (deletes all data).

Environment Variables in Ktor

Your Ktor application reads environment variables using application.conf or directly in code:

// Read from environment variable with fallback
val dbUrl = System.getenv("DB_URL") ?: "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"
val dbUser = System.getenv("DB_USER") ?: ""
val dbPassword = System.getenv("DB_PASSWORD") ?: ""
val dbDriver = System.getenv("DB_DRIVER") ?: "org.h2.Driver"

When DB_URL is not set (local development), the application falls back to H2 in-memory database. When running in Docker, the environment variable points to PostgreSQL.

This pattern lets you run the same code locally (H2) and in Docker (PostgreSQL) without changing anything.

Local Development Workflow

For daily development, you do not need Docker. Use the setup from Tutorial #3:

# Start just the database
docker compose up -d db

# Run Ktor locally (uses H2 or connects to Docker PostgreSQL)
./gradlew run

Use Docker Compose only when you want to test the full production setup locally.

Image Size Optimization

Check your image size:

docker images ktor-tutorial

Expected output:

REPOSITORY       TAG       SIZE
ktor-tutorial    latest    ~150MB

If the image is much larger, check:

  1. .dockerignore is missing — The build copies unnecessary files
  2. Not using multi-stage build — The Gradle SDK bloats the image
  3. Using eclipse-temurin:21-jdk instead of 21-jre — JDK includes the compiler, JRE does not

Alpine vs Standard Images

We use eclipse-temurin:21-jre-alpine (Alpine Linux). The standard eclipse-temurin:21-jre is based on Ubuntu and is ~100 MB larger. Alpine works for most applications. If you encounter issues with native libraries, switch to the standard image.

Docker Commands Reference

Here are the Docker commands you will use most often:

# Build the image
docker build -t ktor-tutorial .

# Run a container
docker run -p 8080:8080 ktor-tutorial

# Run in background
docker run -d -p 8080:8080 --name ktor-app ktor-tutorial

# View running containers
docker ps

# View logs
docker logs ktor-app

# Stop a container
docker stop ktor-app

# Remove a container
docker rm ktor-app

# List images
docker images

# Remove an image
docker rmi ktor-tutorial

Docker Compose Commands

# Start all services
docker compose up -d

# View logs (follow mode)
docker compose logs -f

# View logs for one service
docker compose logs -f app

# Stop all services
docker compose down

# Rebuild and restart
docker compose up -d --build

# Remove volumes (deletes data)
docker compose down -v

Debugging Inside Containers

Sometimes you need to inspect what is happening inside a running container:

# Open a shell in the running container
docker exec -it ktor-app sh

# Check environment variables
docker exec ktor-app env

# Check if the app is listening
docker exec ktor-app wget -qO- http://localhost:8080/health

GraalVM Native Image

For faster startup times, you can compile your Ktor application to a native binary with GraalVM. This eliminates the JVM entirely:

JRE image:     ~150 MB, starts in 1-2 seconds
Native image:  ~50 MB, starts in 50-100 milliseconds

GraalVM native images are useful for serverless functions where cold start matters. For long-running servers, the JVM’s JIT compiler gives better peak performance. GraalVM support for Ktor is improving but still requires configuration. If you are interested, check the Ktor GraalVM documentation.

Production vs Development Configuration

Your application behaves differently in Docker vs local development. Here is a comparison:

SettingLocal DevelopmentDocker (Production)
DatabaseH2 in-memoryPostgreSQL
Port8080 (Gradle run)8080 (container)
Hot reloadYes (development mode)No
LoggingDEBUG levelINFO level
SecretsHardcoded defaultsEnvironment variables

The environment variable pattern handles this automatically:

val dbUrl = System.getenv("DB_URL") ?: "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"

Locally, DB_URL is not set, so H2 is used. In Docker, the environment variable points to PostgreSQL. Same code, different behavior.

Testing the Docker Image Locally

Before deploying, verify the Docker image works with PostgreSQL:

# Start PostgreSQL only
docker compose up -d db

# Wait for it to be healthy
docker compose ps

# Build and run your app
docker compose up -d app

# Test the API
curl http://localhost:8080/health
curl http://localhost:8080/api/notes

# Check logs for errors
docker compose logs app

If you see database connection errors, check that the DB_URL environment variable matches the PostgreSQL service name (db) and port (5432).

Common Mistakes

  1. Not using multi-stage builds — Your image includes the entire Gradle SDK and source code. Use multi-stage builds to keep only the fat JAR.

  2. Running as root — The default Docker user is root. Always create and switch to a non-root user.

  3. Forgetting .dockerignore — Without it, Docker copies .git, build/, and IDE files into the image.

  4. Not setting JVM memory limits — The JVM uses the host’s memory by default, not the container’s. Use -XX:+UseContainerSupport and -XX:MaxRAMPercentage.

  5. Hardcoding database credentials — Use environment variables. Never put passwords in application.conf or source code.

  6. Not waiting for database health — Use depends_on with condition: service_healthy. Without it, the app crashes on startup because PostgreSQL is not ready.

Summary

In this tutorial, you learned how to:

  • Build a fat JAR for deployment
  • Write a multi-stage Dockerfile for small images
  • Create a .dockerignore file to speed up builds
  • Run Ktor with PostgreSQL using Docker Compose
  • Tune JVM settings for containers
  • Add Docker health checks
  • Use environment variables for configuration

Your Ktor application now runs in Docker. The same image works on any machine — your laptop, a CI server, or a production VPS.

Source Code

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

github.com/kemalcodes/ktor-tutorial — Branch: tutorial-20-docker

What’s Next?

Your application runs in Docker. In the next tutorial, you will set up CI/CD and Deployment — automated testing, building Docker images, and deploying with GitHub Actions.