CONTAINER APPS · CDNCTL GUIDE
Migrating a multi-service Docker project, end to end with cdnctl
This guide walks through taking a multi-service docker-compose-style project
(web/API services, background workers, a database, a cache and a message bus) and running
it on the CDN.com.tr managed-container platform — entirely from the cdnctl
command line. Replace the placeholder values (<account-uuid>,
image names, ports) with your own.
If you already have a docker-compose.yml, you can skip the manual
create-and-wire steps below: the platform parses your compose file and proposes a
ready-to-confirm plan. See the
Docker Compose import guide
or the
cdnctl compose reference.
The end-to-end steps here remain the way to fine-tune or migrate anything compose import does not cover.
1 · Concepts
- Account = project. One account can hold many container apps; your package determines how many apps you may run.
- App = one container image (a service). Each app has a port, a health check, resources, env vars and secrets, and can scale to N replicas.
- Managed addons are stateful backends —
postgres(PostgreSQL/TimescaleDB),mysql,redisandnats(JetStream) — that you attach to an app. Each addon is provisioned for the app you enable it on and exposes a stable in-cluster hostname. Its connection details — host, port, user, database and the generated password — are injected straight into that app as env vars and secrets (e.g.DATABASE_URL,REDIS_URL,NATS_URL), ready to use. The password is generated for you and is never displayed back, so you don't build connection strings by hand for the owning app. - Private networking & service discovery. Apps in your account share a
private network. Each app is reachable from your other apps by its app name
— exactly like a docker-compose service name — at
http://<app-name>:<port>(we publish an internal DNS alias = your app name). So if your compose file talks tohttp://hot-data-store:8082, name that apphot-data-storewith port8082and it just works. Apps can reach the internet but not other customers' private services. - Addon hosts are returned to you. When you enable an addon, its stable
in-cluster host is reported by
cdnctl container addons list(thehostfield). That is the<db-host>/<redis-host>/<nats-host>used in the connection strings below — you don't guess it. - Public exposure. Any app can be published on a CDN.com.tr subdomain or your own domain. Each exposed service gets its own hostname.
2 · Configure cdnctl
Authenticate once with your panel API token (or login with your credentials):
cdnctl configure --endpoint https://cdn.com.tr --token <your-api-token>
# verify
cdnctl accounts list
cdnctl container apps list --account <account-uuid>
3 · Build & publish images, register a pull credential
Build each service for linux/amd64 and push to a registry the platform can pull.
If the repos are private, register a pull credential on the account:
# build & push (one image per service)
docker buildx build --platform linux/amd64 -t your-registry/app-web:1.0.0 --push .
docker buildx build --platform linux/amd64 -t your-registry/app-worker:1.0.0 --push .
# register pull credentials (token is stored encrypted, never returned)
cdnctl container registry-credentials create --account <account-uuid> \
--name registry --registry-url https://index.docker.io/v1/ \
--username <user> --password <token>
cdnctl container registry-credentials list --account <account-uuid>
Never bake secrets into images. Use a .dockerignore to exclude
.env, build artifacts and VCS data.
4 · Provision managed addons (DB, cache, message bus)
Each addon is attached to an app, so you create that app first (step 5) and then enable the addons on it. The addon is provisioned for that app and its connection is injected into it automatically — you don't assemble the URL or copy a password:
# enable the backends your owner app needs (app created in step 5):
cdnctl container addons enable-postgres --account <account-uuid> --app <app-uuid> --storage-mb 10240
cdnctl container addons enable-redis --account <account-uuid> --app <app-uuid>
cdnctl container addons enable-nats --account <account-uuid> --app <app-uuid> --storage-mb 5120
cdnctl container addons list --account <account-uuid> --app <app-uuid>
From now on that app's environment already contains ready-to-use values — you reference them, you don't build them:
# auto-injected into the app the addon is enabled on:
DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER # env
DATABASE_PASSWORD, DATABASE_URL # secrets (full postgres:// URL)
REDIS_URL # redis://<redis-host>:6379/0
NATS_URL # nats://<nats-host>:4222
cdnctl container addons list reports each addon's stable host, port,
user and database name (e.g. host: ca-…-postgres) for reference —
the generated password is never returned. So you don't need to hand-write
DATABASE_URL for the owning app; it's already a secret on it.
Sharing a backend across apps. Passwordless backends — NATS,
and Redis as configured here — can be reached from your other apps too: take the
host from addons list and set NATS_URL/REDIS_URL
env on each consumer (step 5). A password-protected SQL database is different: because the
password is only ever injected into the app that owns the addon (and never shown), don't try to
reuse it from a second app. Instead, either let one app own the database and have the others call
it over your API, or run your own database image with a password you set yourself. App-to-app
hosts are just your app names (the docker-compose-style alias from step 1), so URLs like
http://hot-data-store:8082 resolve with no docker-compose.yml uploaded.
5 · Create & wire each service
Create one app per service. The order is: create the app → enable its addons (step 4) → deploy (step 6). Enabling an addon redeploys the app with the connection injected, so create the addon-owning app here first, then run the step-4 commands against it. Pick the right health check per service type:
--healthcheck-type http(default) for HTTP services — set--healthcheck /your/path.--healthcheck-type tcpfor TCP services without an HTTP endpoint.--healthcheck-type nonefor background workers that don't listen.
# app-web OWNS the postgres/redis/nats addons you enable in step 4, so DATABASE_URL,
# REDIS_URL and NATS_URL are injected for you — you only add your own secrets here:
cdnctl container apps create --account <account-uuid> \
--name app-web --image your-registry/app-web --tag 1.0.0 \
--port 8080 --healthcheck-type http --healthcheck /health \
--metrics-port 9090 --metrics-path /metrics \
--registry-credential <cred-uuid> \
--secrets-json '{"API_KEY":"<secret>"}'
# a background worker that shares the message bus. It does NOT own the addons, so point it
# at the passwordless NATS host from `addons list` (there is no DB password to copy):
cdnctl container apps create --account <account-uuid> \
--name app-worker --image your-registry/app-worker --tag 1.0.0 \
--healthcheck-type none --registry-credential <cred-uuid> \
--env-json '{"NATS_URL":"nats://<nats-host>:4222"}' \
--secrets-json '{"API_KEY":"<secret>"}'
Secrets passed with --secrets-json are stored as encrypted
Kubernetes secrets and never placed in plain config or logs. Update env/secrets later with
cdnctl container apps update --account <account-uuid> --app <app-uuid> --env-json ... --secrets-json ...
6 · Deploy, check status & logs
Deploy each app (deploy the service that initialises the database schema first, if your apps run migrations on boot):
cdnctl container apps deploy --account <account-uuid> --app <app-uuid>
cdnctl container apps status --account <account-uuid> --app <app-uuid>
cdnctl container apps wait --account <account-uuid> --app <app-uuid> --status running --timeout 300
cdnctl container apps logs --account <account-uuid> --app <app-uuid> --tail 100
cdnctl container apps diagnose --account <account-uuid> --app <app-uuid>
7 · Expose services publicly
Give a service its own CDN.com.tr subdomain (HTTPS is handled for you):
cdnctl container apps expose --account <account-uuid> --app <app-uuid>
# returns the app's public_subdomain, e.g. https://<uid>.cdn.com.tr
Each app gets one immutable subdomain; expose as many services as you need — one account, many public hostnames. To use your own domain instead, add it as a delivery point and point it at the app (your domain → CDN → the service).
Expose only services that should be public. Background workers and databases stay private. Add authentication to any HTTP service you publish.
8 · Observability (single pane of glass)
Run your monitoring stack as ordinary apps: a metrics collector that scrapes your services, plus a dashboard you expose publicly. Keep the collector internal and expose only the dashboard:
# collector (internal): build an image with your scrape config baked in
cdnctl container apps create --account <account-uuid> --name metrics \
--image your-registry/metrics --tag 1.0.0 --port 9090 --healthcheck-type http --healthcheck /-/healthy \
--registry-credential <cred-uuid>
cdnctl container apps deploy --account <account-uuid> --app <metrics-app-uuid>
# dashboard: wire it to the collector via env, then expose it
cdnctl container apps create --account <account-uuid> --name dashboard \
--image your-registry/dashboard --tag 1.0.0 --port 3000 --healthcheck-type http --healthcheck /api/health \
--registry-credential <cred-uuid> \
--env-json '{"METRICS_URL":"http://<metrics-host>:80"}' --secrets-json '{"ADMIN_PASSWORD":"<secret>"}'
cdnctl container apps deploy --account <account-uuid> --app <dashboard-app-uuid>
cdnctl container apps expose --account <account-uuid> --app <dashboard-app-uuid>
9 · Lifecycle & rollback
cdnctl container apps scale --account <account-uuid> --app <app-uuid> --replicas 0 # stop
cdnctl container apps restart --account <account-uuid> --app <app-uuid>
cdnctl container apps delete --account <account-uuid> --app <app-uuid> # removes app + its subdomain
cdnctl container addons disable-postgres --account <account-uuid> --app <app-uuid> # keeps data
cdnctl container addons disable-postgres --account <account-uuid> --app <app-uuid> --delete-data --confirmation <app-name>
Disabling a data addon keeps its volume by default; deleting data requires an explicit confirmation. Deleting an app also removes its public subdomain.