Skip to content
SHC Docs

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.


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.

compose.yaml
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, FLUSHALL from 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.

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.

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.


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.

backup.yaml
# Ephemeral infra volume: the cache rebuilds from the integrated
# database on boot. House rule: redis/valkey/kafka/rabbitmq volumes
# are ephemeral.
exclude:
mounts:
- valkey-data

The 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.

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:

PieceWhereWhat it does
converterapps/mailcow/backup.yaml (mailcow-redis)copies /data/dump.rdb out of redis-data into the capture output
restore jobapps/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.


SHC does publish a redis socket, provided by two apps:

ProviderUse
redisa redis stack SHC deploys and operates, integrated by other stacks
external-redisa 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:

redis.plug.yaml
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 service
environment:
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 }}".


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 in app.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.