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:
.dockerignoreis missing — The build copies unnecessary files- Not using multi-stage build — The Gradle SDK bloats the image
- Using
eclipse-temurin:21-jdkinstead of21-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:
| Setting | Local Development | Docker (Production) |
|---|---|---|
| Database | H2 in-memory | PostgreSQL |
| Port | 8080 (Gradle run) | 8080 (container) |
| Hot reload | Yes (development mode) | No |
| Logging | DEBUG level | INFO level |
| Secrets | Hardcoded defaults | Environment 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
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.
Running as root — The default Docker user is root. Always create and switch to a non-root user.
Forgetting
.dockerignore— Without it, Docker copies.git,build/, and IDE files into the image.Not setting JVM memory limits — The JVM uses the host’s memory by default, not the container’s. Use
-XX:+UseContainerSupportand-XX:MaxRAMPercentage.Hardcoding database credentials — Use environment variables. Never put passwords in
application.confor source code.Not waiting for database health — Use
depends_onwithcondition: 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
.dockerignorefile 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.
Related Articles
- Ktor Tutorial #19: Testing — Tests run in CI before deployment
- Ktor Tutorial #6: Database Setup — PostgreSQL and H2 configuration
- Ktor Tutorial #10: Flyway Migrations — Database migrations run on startup
- Docker Cheat Sheet — Docker commands reference