GreenhouseAdmin is designed for containerized deployment with Docker, automated through GitHub Actions CI/CD pipeline. This guide covers Docker deployment, environment configuration, and production best practices.
Docker Deployment
The project uses a multi-stage Dockerfile to build and serve the WebAssembly application efficiently.
Dockerfile Overview
The Dockerfile implements a two-stage build:
Stage 1 (Builder): Compiles the Kotlin Multiplatform application to WebAssembly
Stage 2 (Runtime): Serves the built application with Nginx
Building the Docker Image
Build the Docker image locally:
# Build for development environment
docker build \
--build-arg API_BASE_URL=https://api-dev.example.com/api/v1/ \
-t greenhouse-admin:dev \
.
The API_BASE_URL build argument is required and must include the full API path including /api/v1/.
Running the Container
Run the built Docker container:
# Run with port mapping
docker run -d \
--name greenhouse-admin \
-p 8080:80 \
greenhouse-admin:dev
# Access the application
open http://localhost:8080
Docker Build Stages
Stage 1: Builder (Lines 6-44)
From Dockerfile:6-44:
FROM gradle:8.10-jdk21 AS builder
# Build argument for API URL (passed from CI/CD)
ARG API_BASE_URL
ENV API_BASE_URL=${API_BASE_URL}
# Install libatomic1 for Node.js v25+ (required by Kotlin/WASM)
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
libatomic1 \
&& rm -rf /var/lib/apt/lists/*
USER gradle
WORKDIR /app
# Configure memory for JVM/Gradle
ENV GRADLE_OPTS= "-Dorg.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m"
# Copy Gradle configuration files first (for better caching)
COPY --chown=gradle:gradle gradle/ gradle/
COPY --chown=gradle:gradle gradlew gradlew.bat settings.gradle.kts build.gradle.kts gradle.properties ./
# Copy the compose app module
COPY --chown=gradle:gradle composeApp/ composeApp/
# Create local.properties with API_BASE_URL
RUN echo "API_BASE_URL=${API_BASE_URL}" > local.properties
# Upgrade Yarn lock files (required after dependency changes)
RUN ./gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock --no-daemon
# Build the WASM distribution
RUN ./gradlew :composeApp:wasmJsBrowserDistribution --no-daemon --stacktrace
Key features:
Uses Gradle 8.10 with JDK 21
Installs libatomic1 for Node.js v25+ compatibility
Allocates 4GB memory for Gradle builds
Layer caching optimized (configuration files copied first)
Upgrades Yarn lock files before building
Builds production WebAssembly distribution
Stage 2: Nginx Server (Lines 46-69)
From Dockerfile:46-69:
FROM nginx:alpine
# Install required packages
RUN apk add --no-cache tzdata
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy the built WASM application
COPY --from=builder /app/composeApp/build/dist/wasmJs/productionExecutable/ /usr/share/nginx/html/
# Create health check endpoint
RUN echo "OK" > /usr/share/nginx/html/health
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1
# Start Nginx
CMD [ "nginx" , "-g" , "daemon off;" ]
Key features:
Lightweight Alpine Linux base
Custom Nginx configuration for WASM
Health check endpoint at /health
Automatic health monitoring
Nginx Configuration
The nginx.conf provides production-ready configuration for serving WebAssembly applications.
From nginx.conf:52-55:
# WASM headers for SharedArrayBuffer support (required by Skiko/Compose)
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
These headers are required for Compose Multiplatform WebAssembly to work correctly. Without them, the application will fail to load.
WASM MIME Type
From nginx.conf:16-18:
# Add WASM MIME type
types {
application/wasm wasm;
}
Caching Strategy
Optimized caching for different asset types:
1. Never cache: index.html and entry point JavaScript (Lines 62-82)
# index.html: NEVER cache aggressively
location = /index.html {
add_header Cache-Control "no-cache" always;
}
# Entry point JS (composeApp.js): revalidate always
location ~* ^/(composeApp\.js|styles\.css)$ {
add_header Cache-Control "no-cache" always;
}
2. Cache forever: Hashed assets (Lines 84-104)
# Hashed JS chunks (446.js, 886.js, etc.): cache forever
location ~* ^/[0-9]+\.js$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
}
# WASM files with hashes: cache forever
location ~* \.wasm$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
}
From nginx.conf:46-50:
# Security headers (applied to all responses)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
SPA Routing
From nginx.conf:57-60:
# SPA routing: fallback to index.html
location / {
try_files $ uri $ uri / /index.html;
}
Health Check
From nginx.conf:112-117:
# Health check endpoint (for Kubernetes/Docker)
location /health {
access_log off ;
return 200 "OK \n " ;
add_header Content-Type text/plain;
}
Environment Configuration
Build-Time Configuration
The API URL is injected at build time using BuildKonfig:
// Access in code
import com.apptolast.greenhouse.admin.BuildKonfig
val apiUrl = BuildKonfig.API_BASE_URL
Environment Variables
For Docker builds, pass API_BASE_URL as a build argument. For local development, set it in local.properties.
Docker:
docker build --build-arg API_BASE_URL=https://api.example.com/api/v1/ .
Local:
# local.properties
API_BASE_URL =https://api-dev.example.com/api/v1/
CI/CD Pipeline
Automated deployment using GitHub Actions.
Pipeline Overview
From .github/workflows/ci-cd.yml:
name : CI/CD Pipeline
on :
push :
branches :
- develop
- main
pull_request :
branches :
- develop
- main
Build Job (Lines 27-67)
Checkout Code
- name : Checkout code
uses : actions/checkout@v4
Setup JDK 21
- name : Set up JDK 21
uses : actions/setup-java@v4
with :
java-version : '21'
distribution : 'temurin'
cache : 'gradle'
Configure Environment
- name : Create local.properties with API URL
run : |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "API_BASE_URL=${{ secrets.API_BASE_URL_PROD }}" > local.properties
else
echo "API_BASE_URL=${{ secrets.API_BASE_URL_DEV }}" > local.properties
fi
Upgrade Yarn Lock Files
- name : Upgrade Yarn Lock files
run : |
./gradlew kotlinUpgradeYarnLock --no-daemon
./gradlew kotlinWasmUpgradeYarnLock --no-daemon
Build WASM Distribution
- name : Build WASM Distribution
run : ./gradlew :composeApp:wasmJsBrowserDistribution --no-daemon --stacktrace
Upload Artifacts
- name : Upload build artifacts
uses : actions/upload-artifact@v4
with :
name : wasm-distribution
path : composeApp/build/dist/wasmJs/productionExecutable/
retention-days : 1
Docker Job (Lines 72-137)
Setup Docker Buildx
- name : Set up Docker Buildx
uses : docker/setup-buildx-action@v3
Login to Docker Hub
- name : Login to Docker Hub
uses : docker/login-action@v3
with :
username : ${{ secrets.DOCKERHUB_USERNAME }}
password : ${{ secrets.DOCKERHUB_TOKEN }}
Generate Version Tag
- name : Generate version
id : version
run : |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
VERSION="prod-$(date +'%Y%m%d%H%M%S')-${GITHUB_SHA::7}"
else
VERSION="dev-$(date +'%Y%m%d%H%M%S')-${GITHUB_SHA::7}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
Build and Push Image
- name : Build and push Docker image
uses : docker/build-push-action@v6
with :
context : .
push : true
tags : ${{ steps.meta.outputs.tags }}
cache-from : type=gha
cache-to : type=gha,mode=max
platforms : linux/amd64
build-args : |
API_BASE_URL=${{ steps.api_url.outputs.url }}
Required GitHub Secrets
Configure these secrets in your GitHub repository:
Secret Description Example API_BASE_URL_DEVDevelopment API URL https://api-dev.example.com/api/v1/API_BASE_URL_PRODProduction API URL https://api.example.com/api/v1/DOCKERHUB_USERNAMEDocker Hub username apptolastDOCKERHUB_TOKENDocker Hub access token dckr_pat_...
Health Checks and Monitoring
Docker Health Check
The container includes built-in health checking:
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1
Check container health:
docker ps
# Look for "healthy" status
docker inspect --format= '{{.State.Health.Status}}' greenhouse-admin
Health Endpoint
Access the health endpoint:
curl http://localhost:8080/health
# Response: OK
Kubernetes Probes (Example)
If deploying to Kubernetes:
apiVersion : v1
kind : Pod
metadata :
name : greenhouse-admin
spec :
containers :
- name : app
image : apptolast/greenhouse-admin:latest
ports :
- containerPort : 80
livenessProbe :
httpGet :
path : /health
port : 80
initialDelaySeconds : 5
periodSeconds : 10
readinessProbe :
httpGet :
path : /health
port : 80
initialDelaySeconds : 5
periodSeconds : 5
Production Deployment Checklist
Environment Configuration
Troubleshooting
Docker Build Failures
Issue: Out of memory during Gradle build
Solution:
# Increase memory in Dockerfile
ENV GRADLE_OPTS= "-Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g"
Issue: Yarn lock file errors
Solution:
# The Dockerfile already handles this, but for local builds:
docker build --no-cache .
Runtime Issues
Issue: Application loads but shows white screen
Solution: Check browser console for CORS errors. Ensure nginx configuration includes:
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
Issue: Health check fails
Solution:
# Check nginx logs
docker logs greenhouse-admin
# Verify health endpoint manually
docker exec greenhouse-admin wget -O- http://localhost/health
Container Monitoring
# View container logs
docker logs -f greenhouse-admin
# Check container resource usage
docker stats greenhouse-admin
# Inspect container configuration
docker inspect greenhouse-admin
Next Steps
Setup Set up your local development environment
Building Learn how to build for different platforms