Best Practices

How to build apps that run reliably on Kyper — from Dockerfile tips to production validation with kyper test.

Validate with kyper test

The most common cause of review failures is an app that works locally but fails in production. kyper test builds your app and deploys it to Kyper's actual K8s infrastructure — full stack, including any deps you declared — so you can catch issues before submitting for review.

bash
# From your project root (same directory as kyper.yml)
kyper test
1.
Your source is built using the same Docker pipeline as kyper push. Build logs stream in real-time.
2.
On success, a temporary deployment is created at a unique URL — e.g., test-myapp-a1b2c3d4.apps.kyper.shop.
3.
The deployment auto-destroys after 1 hour. You can tear it down early with kyper test --destroy.

Limits

  • 1 active test deploy at a time (per developer)
  • Hobby tier resources: 512 MB RAM, 0.1 vCPU
  • Not for production use — auto-destroyed after 1 hour

Check status or get the URL of a running test deploy:

bash
kyper test status

If your app boots but isn't working (500 errors, blank pages), check runtime container logs:

bash
# View the last 100 lines of container output
kyper test logs

# View more history
kyper test logs --tail 300

Tear down a running test deploy early:

bash
kyper test destroy

Dockerfile tips

Kyper builds your image from your Dockerfile. These practices keep builds fast, images small, and runtime behaviour predictable.

Use multi-stage builds

Separate build and runtime stages to keep your final image lean. Build tools (compilers, dev dependencies) stay out of production.

dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

Add a .dockerignore

Exclude files that don't belong in your image. Smaller build context = faster uploads and builds.

bash
.git
node_modules
tmp/
log/
*.log
.env

Don't run as root

Create a non-root user for your app process. This is required for Kyper's security scan to pass.

dockerfile
RUN addgroup -S app && adduser -S app -G app
USER app

Install dependencies during build, not at runtime

npm install, bundle install, pip install — all of these must run in the Dockerfile, not in your entrypoint script. Containers have no network access after the image is built.

Health checks

Kyper's K8s cluster sends HTTP GET requests to your app's health check path to determine when it's ready to receive traffic — and to restart it if it becomes unhealthy. Configure this in your kyper.yml:

yaml
healthcheck:
  path: /up
  initial_delay: 15
  period: 30
path Must return HTTP 200. Keep it cheap — no database queries, no external calls.
initial_delay Seconds to wait before the first check. Give your app time to boot and run migrations. Default: 15s.
period Seconds between subsequent checks. Default: 30s.
If your health check path requires authentication, it will always fail. Use a dedicated unauthenticated endpoint like /up or /health.

Environment variables

Declare every env var your app needs in kyper.yml. Consumers will be prompted to supply them before their deployment starts.

yaml
env:
  - SENDGRID_API_KEY
  - OPENAI_API_KEY
  - MY_APP_SECRET

Kyper auto-injects these — do not declare them:

  • PORT
  • SECRET_KEY_BASE
  • DATABASE_URL
  • REDIS_URL
  • POSTGRES_PASSWORD
  • MYSQL_PASSWORD
  • AWS_ACCESS_KEY_ID
  • AWS_SECRET_ACCESS_KEY
  • AWS_BUCKET
  • AWS_ENDPOINT_URL
  • KYPER_DEPLOYMENT_ID

Your app must listen on port 3000. PORT is always set to 3000. Kyper's ingress routes external traffic to this port.

Never hardcode secrets in your Dockerfile or source code. Use the env: declaration and let consumers supply their own credentials.

Testing with env vars

If your app declares env vars in kyper.yml, supply values for them during kyper test via a local .env file. The CLI reads it automatically, or you can point at any file with --env-file:

bash
# .env is loaded by default
kyper test

# Or use a specific file
kyper test --env-file .env.test

Standard KEY=VALUE format. Blank lines and # comments are ignored. If the file doesn't exist, the test proceeds without developer-supplied env vars — no error is raised. Reserved keys (DATABASE_URL, REDIS_URL, etc.) are always overridden by Kyper regardless of what's in the file.

Make sure .env is in your .kyperignore so it's never uploaded with your source archive.

Resource limits

Each deployment runs within fixed resource limits determined by the consumer's chosen tier. Your app is subject to hard limits — exceeding memory causes an OOMKill restart.

Tier RAM CPU Storage
Hobby 512 MB 0.1 vCPU 1 GB
Basic 512 MB 0.25 vCPU 1 GB
Pro 1 GB 0.5 vCPU 5 GB
Turbo 2 GB 1 vCPU 10 GB

kyper test always uses Hobby tier resources. If your app needs more, mention it in your listing description and review notes so consumers choose the right tier.

Keep your base memory footprint low. Lazy-load large dependencies, avoid pre-forking with too many workers, and profile locally with memory limits:

bash
# Test with Hobby tier memory limit
docker run --memory=512m --memory-swap=512m my-app

Stack-specific guidance

Platform-specific tips for the most common stacks on Kyper. General advice (health checks, env vars, SIGTERM) applies to all — these sections go deeper.

Ruby on Rails

SolidQueue vs Sidekiq

If your only reason for declaring a redis: dep is to run Sidekiq, consider switching to SolidQueue instead. SolidQueue is database-backed and ships with Rails 8 — no Redis dep needed. Removing Redis saves consumers money (every declared dep adds infrastructure overhead).

If your app genuinely needs Redis for caching, pub/sub, or ActionCable, declare the redis: dep — but consider whether SolidCache or SolidCable could replace the need entirely.

Puma workers and memory

Each Puma worker consumes ~150–300 MB on a typical Rails app. Size your WEB_CONCURRENCY accordingly:

Tier RAM Recommended WEB_CONCURRENCY
Hobby512 MB1
Basic512 MB1
Pro1 GB2
Turbo2 GB3-4

Declare WEB_CONCURRENCY in your env: list so consumers can tune it.

Asset precompilation

Run rails assets:precompile in your Dockerfile build stage, not at boot time. Precompiling at container start blocks traffic, consumes extra memory, and fails if the filesystem is read-only.

dockerfile
RUN SECRET_KEY_BASE=placeholder rails assets:precompile

Skip Rails encrypted credentials

Don't ship a master.key in your image. Use env: declarations instead — Kyper injects them as K8s Secrets. Your .kyperignore should exclude config/master.key and config/credentials.yml.enc.

Node.js

Don't use PM2 inside Docker

PM2 is a process manager that fights with Kubernetes' own restart and lifecycle management. Run your app directly — CMD ["node", "dist/index.js"]. K8s handles restarts; your process just needs to exit cleanly on SIGTERM.

Cap the V8 heap on Hobby and Basic

V8's default memory limit is ~1.5 GB — more than the entire 512 MB allocation on Hobby and Basic. Set an explicit cap to avoid OOMKill:

dockerfile
CMD ["node", "--max-old-space-size=400", "dist/index.js"]

Leave ~100 MB headroom for the OS and Node overhead beyond the V8 heap.

TypeScript: build in Docker, don't ship source

Compile TypeScript in a build stage. Only copy the compiled dist/ output to your final image — no ts-node, no source files, no devDependencies at runtime.

Graceful shutdown

Many Node.js frameworks don't wire SIGTERM by default. Add it explicitly:

javascript
process.on('SIGTERM', () => {
  server.close(() => process.exit(0));
});

Python

WSGI vs ASGI

Use Gunicorn for Flask and Django (WSGI). Use uvicorn + Gunicorn for FastAPI or Django Channels (ASGI):

dockerfile
# Flask / Django (WSGI)
CMD ["gunicorn", "myapp.wsgi:application", "--bind", "0.0.0.0:3000", "--workers", "2"]

# FastAPI (ASGI)
CMD ["gunicorn", "main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:3000", "--workers", "2"]

Worker count

Gunicorn's default formula (2 × CPU + 1) can OOMKill on small tiers. Use --workers 1 on Hobby and Basic, --workers 2 on Pro, and --workers 4 on Turbo. Declare a GUNICORN_WORKERS env var so consumers can tune it.

Static files (Django)

Use WhiteNoise to serve Django static files from the app process itself — no separate nginx or static file server needed. Add it to your middleware and run collectstatic in your Dockerfile.

No virtualenv inside Docker

Don't create a venv inside your image. Install packages directly into the system Python (or use a build stage). The container itself is the isolation layer.

Go

Multi-stage builds for minimal images

Go compiles to a static binary. Use a multi-stage build with a minimal final image — Go apps have the smallest footprint of any common stack on Kyper:

dockerfile
FROM golang:1.23-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server

FROM alpine:3.20
COPY --from=build /app/server /server
CMD ["/server"]

CGO and scratch images

If your app uses CGO (SQLite, certain crypto libs), FROM scratch won't work — you need libc. Use FROM alpine or FROM gcr.io/distroless/base instead.

Graceful shutdown

go
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()

// pass ctx to your server and shut down cleanly when it's canceled

Common gotchas

Database migrations

Run migrations in your hooks.on_deploy — not in your entrypoint. Running migrations in CMD means they re-run on every container restart and block health checks during the migration.

yaml
hooks:
  on_deploy: "rails db:migrate"

Graceful shutdown

Handle SIGTERM. Kubernetes sends SIGTERM before killing a container. If your app ignores it, in-flight requests will be dropped. Most web frameworks handle this automatically — verify yours does.

Filesystem is ephemeral

Don't write files you need to keep to the container filesystem. Use your declared s3 dep for object storage, or a storage_gb volume for persistent data.

Logs go to stdout/stderr

Don't write logs to files. Kyper captures stdout/stderr from your container. File-based logging means logs are silently lost on restart.

No inbound connections (except HTTP)

Consumers can only reach your app via HTTPS on the assigned domain. SSH, raw TCP, and custom ports are not supported. All inter-process communication must go through the shared K8s namespace.

Pre-submission checklist

Before running kyper push, verify:

☐ kyper check passes with no errors
☐ kyper build succeeds locally
☐ kyper test passes — app is live and functional at the test URL
☐ App listens on PORT (3000) — not a hardcoded port
☐ Health check path returns 200 without authentication
☐ All required env vars are declared in kyper.yml
☐ No secrets hardcoded in Dockerfile or source
☐ Database migrations run via on_deploy hook, not CMD (if applicable)
☐ App handles SIGTERM gracefully
☐ Logs go to stdout/stderr
☐ Non-root user in Dockerfile
☐ .dockerignore excludes .git, node_modules, tmp, .env