Moving a Docker Compose stack to Kubernetes
Most self-hosted applications start life as a docker-compose.yml on one server. This page maps that file onto a Kipper cluster piece by piece, so you can see what each part becomes before you move anything.
The mapping is manual and you do it once: read the compose file, and what comes out is a set of kip commands or a kipper.yaml you keep in the repository. That is deliberate: a compose file says how containers run on one machine, and a fair amount of it (bind mounts, host networking, a reverse proxy container) has no equivalent here because the platform already does the job.
What each part becomes
In docker-compose.yml | In Kipper |
|---|---|
| A service running your own image | An app: kip app deploy --image |
| A service that builds from a Dockerfile | An app built in the cluster from git: kip app deploy --git |
| A service running postgres, mysql, mongodb, redis, rabbitmq, opensearch or minio | A managed service: kip service add postgres --name db |
ports: "80:3000" | --port 3000. The public side is HTTPS on a hostname, and you never publish a port |
environment: and env_file: | Environment variables: kip app env set app KEY=VALUE or --from-file |
A DATABASE_URL you wrote by hand | kip service bind db app, which injects the credentials it generated |
depends_on: | Nothing, and your app needs to cope. Start order is not guaranteed, and a container that stays alive while its database is missing is never restarted for you |
| A named volume shared between containers | A shared volume: kip volume create then kip volume mount |
| A bind mount of a host directory | Either a shared volume, or object storage. There is no host path to mount |
| Another container reached by its compose name | kip app link, which injects the target's internal URL |
| An nginx, Traefik or Caddy container in front | Built in: the cluster routes and terminates TLS for you |
restart: always | Built in: Kubernetes restarts a failed container by default |
deploy.replicas: 3 | --replicas 3, or autoscaling |
| A cron container, or ofelia | A scheduled job: kip job schedule --cron |
A worked example
Take a small stack: a Node app that builds from the repository, Postgres, Redis, and nginx in front of it.
services:
web:
build: .
ports: ["80:3000"]
env_file: .env
depends_on: [db, cache]
volumes:
- uploads:/app/public/uploads
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: hunter2
volumes:
- pgdata:/var/lib/postgresql/data
cache:
image: redis:7
nginx:
image: nginx
ports: ["443:443"]
volumes:
- ./nginx.conf:/etc/nginx/nginx.confThat becomes four commands and no nginx.
kip project create shop --environments test,prod
kip service add postgres --name db --project shop --environment prod --storage 10Gi
kip service add redis --name cache --project shop --environment prod
kip app deploy --name web --git https://github.com/acme/shop --port 3000 \
--project shop --environment prodThen connect them. kip service bind injects the service's connection details into the app, so no password is written down anywhere:
kip service bind db web --project shop --environment prod
kip service bind cache web --project shop --environment prodA database binding injects the connection in parts, prefixed DB_: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD and DB_NAME. The credentials are the ones generated when the service was created. Redis arrives as REDIS_HOST and REDIS_PORT, because it runs without a password. An app that wants one DATABASE_URL builds it from those parts, either in its own code or in the manifest below.
A plain bind attaches the app to the service's own database, which is what a single-app compose stack wants. Several apps on one Postgres is the case to think about: pass --database shop_web (or database: in the manifest) and that app gets a database of its own inside the same service.
The .env file carries across as it is:
kip app env set web --from-file .env --project shop --environment prod --restartLeave the compose-era POSTGRES_PASSWORD and DATABASE_URL out of that file. The binding generated its own credentials, and a stale copy sitting in the environment is the usual reason an app moves across and then connects to nothing.
The uploads volume, if the app really does need a shared filesystem rather than object storage:
kip volume create uploads --size 10Gi --project shop --environment prod
kip volume mount uploads web --path /app/public/uploads --project shop --environment prodpgdata needs no equivalent. A managed service brings its own persistent volume, sized with --storage.
The same thing as a manifest
The commands are the quick way in. For anything you will change again, put it in a kipper.yaml instead and keep it next to the code:
project: shop
environment: prod
environments:
- test
- prod
apps:
web:
git:
url: https://github.com/acme/shop
branch: main
port: 3000
env:
DATABASE_URL: postgres://${DB_USERNAME}:${DB_PASSWORD:urlencode}@${DB_HOST}:${DB_PORT}/${DB_NAME}
serviceBindings:
- name: db
prefix: DB_
- name: cache
route:
host: shop.example.com
services:
db:
type: postgres
version: "16"
storage: 10Gi
cache:
type: redis
volumes:
uploads:
size: 10Gi
mounts:
- app: web
mountPath: /app/public/uploadskip diff -f kipper.yaml shows what would change, and kip apply -f kipper.yaml makes it so.
An app declared with a git block is created holding a placeholder image, because the manifest describes a repository rather than a build. kip app rebuild web --project shop --environment prod runs the first one. kip app rebuild takes the default project when you leave the scope off, so pass it here. Set up a webhook if you want a push to build without you.
What does not carry across
- Bind mounts of host paths.
./nginx.conf:/etc/nginx/nginx.confand friends assume a filesystem your containers share with the host. Configuration belongs in the image or in environment variables; files your app writes belong in a volume or in object storage. - A reverse proxy container. Routing, TLS, redirects, rate limiting and the security headers are the platform's job. Keeping your own proxy in front means maintaining certificates it will not renew for you.
network_mode: hostand published ports. Apps reach each other by service name inside the cluster, and the outside world reaches them by hostname. Nothing needs a port on the host.- Reaching another container by its compose name. Inside one project the DNS name is
service.namespace.svc.cluster.local. Between projects there is no path inside the cluster until you make one withkip app link, which opens exactly the one port it needs. Anything with a public route is still reachable the way the rest of the internet reaches it. - Privileged containers and anything mounting the Docker socket. A build agent that drives the host Docker daemon has no home here. Builds run in the cluster from git.
After the move
Check the app is serving and its dependencies are wired:
kip app list --project shop --environment prod
kip service list --project shop --environment prod
kip app env list web --project shop --environment prod
kip app logs web --project shop --environment prodMoving the data itself is a separate job. kip service import loads a database dump into a managed service, which is the usual path for a Postgres or MySQL container you are retiring. See stateful services for the dump and restore commands.