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.
# From your project root (same directory as kyper.yml) kyper test
kyper push. Build logs stream in real-time.test-myapp-a1b2c3d4.apps.kyper.shop.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:
kyper test status
If your app boots but isn't working (500 errors, blank pages), check runtime container logs:
# 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:
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.
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.
.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.
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:
healthcheck: path: /up initial_delay: 15 period: 30
/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.
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:
# .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:
# 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 |
|---|---|---|
| Hobby | 512 MB | 1 |
| Basic | 512 MB | 1 |
| Pro | 1 GB | 2 |
| Turbo | 2 GB | 3-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.
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:
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:
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):
# 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:
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
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.
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: