Apps and patches
Every app becomes a Deployment + Service reachable in-namespace at
<name>:<servicePort> (default 8080). Apps deploy after all services
are provisioned, so an app never boots before its database exists, and each
readiness probe gates up completing.
Each app's readiness doubles as its health check on dragonfly's APPS
card — one definition drives both the kubelet gate and live monitoring.
Every app pod also receives MAYFLY_APP_NAME (its own name) and
MAYFLY_APP_CHECKS (the environment's app-check list) in its env.
A worked TCP-app example — pgbouncer in front of the RDS postgres (in
production it might live on an EC2 instance; in an ephemeral environment a
container is the pragmatic stand-in). The rds-appdb secret's
DATABASE_URL is all its image needs:
apps:
pgbouncer:
image: edoburu/pgbouncer:v1.24.1-p1
port: 5432
servicePort: 5432 # -> pgbouncer:5432, protocol-native
secrets: [rds-appdb]
env: {POOL_MODE: transaction, AUTH_TYPE: scram-sha-256}
readiness: {tcp: true}
myapi:
image: ghcr.io/you/myapi:abc
secrets: [rds-appdb]
env: # explicit env beats envFrom: route through the pool
DATABASE_URL: postgresql://app:apppass@pgbouncer:5432/app
apps:
myapi:
enabled: true # default true
image: ghcr.io/you/myapi:sha-abc # required
port: 3000 # container port (default 80)
servicePort: 8080 # in-namespace Service port (default 8080);
# set protocol-native ports for TCP apps
command: ["/bin/server"] # entrypoint override
args: ["--verbose"]
replicas: 2 # default 1
env: {LOG_LEVEL: debug}
secrets: # envFrom these mayfly secrets
- rds-appdb
- {name: elasticache-cache-b, prefix: CACHE_B_}
resources: {cpu: 100m, memory: 128Mi, memoryLimit: 512Mi, cpuLimit: "1"}
readiness:
tcp: false # true -> tcpSocket probe (non-HTTP apps)
path: /healthz
port: 3000 # default: the app port
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 30 # k8s default is 1s — raise it for
# probes that do real work
imagePullSecret: regcred # copied into the namespace at up
# from --pull-secret-namespace
ingress:
host: myapi.example.com # default <app>.<namespace>.localtest.me;
# "*" matches any host
className: alb
annotations: {alb.ingress.kubernetes.io/scheme: internet-facing}
Secret prefixes
Secrets of the same service class share key names (REDIS_HOST, ...), so
mounting several would collide under envFrom. A prefix namespaces them:
{name: elasticache-cache-b, prefix: CACHE_B_} yields
CACHE_B_REDIS_HOST, CACHE_B_REDIS_PORT, etc. Same mechanism for apps
that need two databases.
Patches: the escape hatch
Anything without a dedicated field goes in patch: (merged onto the
generated Deployment) or ingress.patch: (merged onto the generated
Ingress) — arbitrary YAML applied as the final step before apply, so
manifests never have to live outside mayfly.
Merge semantics:
- Maps merge recursively.
- Lists of named objects (
containers,volumes,env,ports, ...) merge by name — matching entries deep-merge, new entries append. - Other lists replace wholesale. Scalars replace.
After the merge, mayfly re-asserts its invariants — resource name, the
app selector/label (extra labels survive), enableServiceLinks: false —
so a patch can add sidecars, volumes, tolerations, initContainers,
securityContext, TLS blocks, or extra ingress rules, but cannot silently
break the wiring the environment depends on.
apps:
myapi:
image: ghcr.io/you/myapi:abc
patch:
spec:
template:
spec:
tolerations: [{key: gpu, operator: Exists}]
containers:
- name: myapi # merges into the generated container
volumeMounts: [{name: cfg, mountPath: /etc/app}]
- name: sidecar # appends a new one
image: envoy:v1.31
volumes: [{name: cfg, configMap: {name: myapi-cfg}}]
ingress:
host: myapi.example.com
patch:
spec:
tls: [{hosts: [myapi.example.com], secretName: myapi-tls}]
If you find yourself writing the same patch in every spec, that's the signal it should graduate to a dedicated field — open an issue.
Init apps — one-shot environment initialization
initApps: entries are Kubernetes Jobs that run after services are
provisioned (and their secrets written) and before apps deploy —
sequentially, in declaration order, on every up. They must therefore
be idempotent; that's a feature, not a caveat — it's what makes up
converge. Failure fails up with the job's logs printed.
initApps:
migrate:
image: mycorp/backend:PR-456-abc123 # usually the app's own image
command: ["bundle", "exec", "rails", "db:prepare"]
secrets: [rds-appdb]
env:
# migrations go DIRECT to the db — advisory locks and prepared
# statements break behind a transaction-pooling pgbouncer
DATABASE_URL: postgresql://app:apppass@rds-appdb:5432/app
timeoutSeconds: 600 # default 600
runPolicy: on-change # always (default) | once | on-change
seed-fixtures:
image: mycorp/backend:PR-456-abc123
command: ["bundle", "exec", "rake", "fixtures:load"]
secrets: [rds-appdb]
runPolicy controls when an entry runs across ups: always (default —
every up; converge semantics), once (only if it has never succeeded in
this environment), on-change (when the entry's own config — image,
command, env, ... — differs from its last successful run; the fit for
migrations that should fire exactly when you bump the code version).
The completed Job doubles as the ledger via a config-hash annotation, so
no external state is involved.
Fields mirror apps where they make sense (image, command, args,
env, secrets incl. prefixes, resources, imagePullSecret, patch,
enabled) — there's no port/replicas/readiness/ingress because nothing
serves. Use init apps for anything that initializes the environment:
Rails/Flyway/Alembic migrations, fixture loads, one-shot aws setup calls
(the resident controller variant of such tools belongs in apps:).
Compared to an initContainer, an init app runs once per up instead of
once per pod per replica, has its own logs and timeout, and isn't welded to
one Deployment's lifecycle. mayfly restart does not rerun them; up
does.
Updating running apps
Two flows, both without down:
-
New image tag (the published flow): change the tag in the spec — or override it without editing anything via
--set:mayfly up env.yaml --seed pr-42 \--set apps.backend.image=ghcr.io/acme/backend:pr-42 \--set initApps.migrate.image=ghcr.io/acme/backend:pr-42Server-side apply means only changed Deployments roll, and services/data are untouched. Init apps run first, so with
runPolicy: on-changeon a migrations entry this is the whole PR pipeline: migrations fire exactly when the version bumps, before the app updates. -
Same tag, new content (the dev loop — you rebuilt and pushed/imported over an existing tag): apply sees no diff, so use
mayfly restart env.yaml(all apps) or--app caddis-api --app ...for a subset. It's a rolling restart with rollout waits; the emulator and service pods are never restarted (their state matters).