Bundled services
An app recipe can either bundle a supporting service in its own
compose.yaml or integrate with one SHC already operates, through a
plug and socket. This page states the house
default for the case that comes up most — a cache — and the backup
consequence that follows from it.
The short version:
Bundle your own redis/valkey. Exclude its volume from backup.
Both halves are house convention, not a mechanism the platform enforces. A recipe that does something else still installs; it just owes a comment explaining why.
Bundle the cache
Section titled “Bundle the cache”A cache belongs in the app’s own compose file. This is the normal,
expected shape, and it is what every cache-using app in the catalog does
today — 22 bundled redis/valkey services, and zero consumers of the
redis socket.
services: valkey: image: "{{ vars.images.valkey }}" x-shc-scale: max: 1 restart: unless-stopped # Cache only — no RDB snapshots, no AOF. See "Turn persistence off". command: ["valkey-server", "--save", "", "--appendonly", "no"] expose: - "6379" healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 10s timeout: 5s retries: 5
app: environment: # NOT the bare name "valkey" — see "Address it by FQDN". REDIS_URL: "redis://{{ hosts.valkey }}:6379/0"Reasons this is the default rather than a socket:
- The state is worthless. A cache, a session store, a Celery broker queue, and a Sidekiq job list all rebuild from the primary datastore. There is nothing to preserve across a restore, so the usual argument for integrating — one copy of the data, one backup path — does not apply.
- A per-app instance is isolated. Key collisions,
FLUSHALLfrom a neighbour, and one app’s memory pressure evicting another app’s sessions are all impossible when each stack owns its own. - Upstream expects it. Most recipes wrap an upstream compose file that already ships a cache service. Removing it means diverging from upstream on every version bump.
Address it by FQDN
Section titled “Address it by FQDN”Every stack that bundles a cache tends to name the service valkey or
redis, and they share a tenant network. Point the consumer at
{{ hosts.<service> }} (which renders {env}.{service}.{stack}), never
at the bare service name — a bare name resolves non-deterministically to
whichever co-tenant stack answers first. apps/nextcloud/compose.yaml
carries the long-form comment on this.
If the recipe declares a password variable, wire it end to end:
--requirepass on the server, credentials in the consumer’s URL, and
-a in the healthcheck. A declared-but-unwired password variable mints
a vault secret, tells the operator the cache is authenticated, and
leaves it open on the tenant network.
Turn persistence off
Section titled “Turn persistence off”Redis and valkey default to RDB snapshotting on (--save 3600 1 300 100 60 10000). An app that then excludes the volume from backup is
paying for disk writes nobody will ever read. Pass
--save '' --appendonly no so the cache stays in memory.
Leave persistence on only when the recipe genuinely wants a warm cache
across restarts, and say so in a comment — apps/highlight/compose.yaml
does exactly that with a deliberate --save 60 1.
Exclude the volume from backup
Section titled “Exclude the volume from backup”The catalog’s informal statement of this lives in
apps/blockscout/backup.yaml:
House rule: redis/valkey/kafka/rabbitmq volumes are ephemeral.
Restoring a captured cache is not merely wasteful — it is a correctness hazard. The cache is captured at a different instant than the primary datastore, so a restore can seed the app with entries that disagree with the database it was restored alongside. Dropping the cache is the safe outcome: it repopulates on first miss.
# Ephemeral infra volume: the cache rebuilds from the integrated# database on boot. House rule: redis/valkey/kafka/rabbitmq volumes# are ephemeral.exclude: mounts: - valkey-dataThe same reasoning covers every derived or transient service — kafka and
zookeeper (apps/highlight), rabbitmq (apps/plane), temporal
(apps/postiz), memcached (apps/mailcow).
It does not cover a bundled service that is a primary store. A bundled datastore holding data that exists nowhere else must be captured like any other state: graphiti’s neo4j, wazuh’s OpenSearch indexer, and ciso-assistant’s qdrant all are.
The documented exception: mailcow
Section titled “The documented exception: mailcow”One app deliberately captures its redis volume — mailcow. Its redis
holds appliance state (rspamd and dovecot data that the mailbox store
cannot reconstruct), and mailcow is a fixed appliance whose components
resolve each other by bare compose service name, so upstream offers no
way to point it at an external instance. It pays the full price for that
decision, in a paired converter and restore job:
| Piece | Where | What it does |
|---|---|---|
| converter | apps/mailcow/backup.yaml (mailcow-redis) | copies /data/dump.rdb out of redis-data into the capture output |
| restore job | apps/mailcow/backup.yaml (mailcow-restore-redis) | copies redis-dump.rdb back into redis-data as dump.rdb |
Capturing a redis volume without both halves is the failure mode to
avoid: the raw volume pass may grab a partially written dump.rdb, and
nothing puts it back where the server looks for it. If an app needs its
cache volume preserved, copy mailcow’s shape.
When the socket is the right answer
Section titled “When the socket is the right answer”SHC does publish a redis socket, provided by two apps:
| Provider | Use |
|---|---|
redis | a redis stack SHC deploys and operates, integrated by other stacks |
external-redis | a managed instance — ElastiCache, Upstash, Memorystore, Azure Cache, Redis Cloud |
It is fully supported and available to any recipe that wants it. It is simply not the default, and no catalog app consumes it today. Reach for it when the deployment genuinely wants one operated cache rather than one per stack — a memory-constrained host running many small apps, a compliance requirement that the cache live on a managed service, or an existing instance the operator already pays for.
Declaring it looks like any other plug:
required: false# Pin the services that actually dial the cache. An unpinned plug# attaches EVERY service to the provider's network and publishes each# short name there as a DNS alias.services: - myapp-worker
data: namespace: "{{ stack }}" database: 0# compose.yaml — read the socket instead of the bundled serviceenvironment: REDIS_URL: "{{ integrations.redis.url }}"Keep the plug optional and guard both paths if the app should still run
without it — apps/gitea/compose.yaml shows the idiom
({% if integrations.postgres is defined or vars.postgres.hostname %}),
and apps/blockscout/postgres.plug.yaml shows the matching
required: "{{ not vars.postgres.hostname }}".
Deciding, in one question
Section titled “Deciding, in one question”Is the bundled thing a stateful service holding data that exists nowhere else?
- No (cache, broker, search index rebuilt from a primary store) — bundle it, exclude its volume, turn persistence off.
- Yes — either integrate it through the matching socket
(
postgres,mysql,mongodb,s3, …), or, if upstream makes that impossible, bundle it and capture it properly with a converter and restore job, and document the constraint inapp.yaml.
The catalog-wide survey behind these numbers, including the per-app
evidence table and the four justifications for a legitimately bundled
service, is
docs/proposals/2026-07-25-app-bundling-vs-sockets-audit.md.
Related
Section titled “Related”- Plugs and sockets — the integration model
- App definition —
backup.yaml— converter and volume schema - Flow: backup/restore — capture and apply order
- Creating apps — the end-to-end tutorial