Deployment
Accessories
Accessories are long-lived support containers that your app depends on but that aren’t part of the rolling application deploy. Think databases, caches, and search indices. wheels deploy boots them once, leaves them alone, and lets you manage their lifecycle independently of the app.
You’ll learn:
- When to use an accessory versus an externally-managed service
- How to declare a Redis or Postgres accessory in
deploy.yml - The independent lifecycle verbs —
boot,reboot,start,stop,logs,remove - How accessories fit into the on-server label and naming scheme
When to use an accessory
Section titled “When to use an accessory”Accessories are convenient, not magical. They’re a good fit when:
- You’re running a single-instance service (one Redis, one Postgres) and managing it alongside the app is simpler than running a managed service.
- Your staging or dev environment shouldn’t pay for managed Redis / RDS.
- You want the database and the app rebuilt together when you tear down an environment (
wheels deploy remove).
Use a managed service — not an accessory — when:
- You need HA, automated failover, or point-in-time recovery.
- The service has its own ops story your team already runs (RDS, ElastiCache, Opensearch Service).
- You’re on Kubernetes and the cluster already has operators for the thing.
Accessories are pinned to one host by default. They’re not a clustering or replication story.
Minimal — Redis
Section titled “Minimal — Redis”accessories: redis: image: redis:7 host: 192.0.2.20 port: "10.0.0.20:6379:6379" # bind address : host port : container portwheels deploy accessory boot redis on first deploy. Produces a container named <service>-redis on the named host. An app container on the same host reaches it at redis://<service>-redis:6379. An app container on another host connects to the published address, redis://10.0.0.20:6379.
Postgres with volume and env
Section titled “Postgres with volume and env”accessories: db: image: postgres:16 host: 192.0.2.20 port: "10.0.0.20:5432:5432" env: clear: POSTGRES_USER: app POSTGRES_DB: myapp_production volumes: - /data/pg:/var/lib/postgresql/dataenv.clear:values becomedocker run -eflags on the accessory container.volumes:persists/var/lib/postgresql/datato the host so the database survivesdocker rm.
Connecting your Wheels app to a database accessory
Section titled “Connecting your Wheels app to a database accessory”A database accessory gives a multi-host app one shared database (a SQLite file is per container, or per host with an app volumes: mount). Four pieces connect the app to it.
1. Declare the accessory, with its password as a secret:
service: myappenv: secret: - POSTGRES_PASSWORD # the app needs it tooaccessories: db: image: postgres:16 host: 192.0.2.20 port: "10.0.0.20:5432:5432" # private interface only env: clear: POSTGRES_USER: app POSTGRES_DB: myapp secret: - POSTGRES_PASSWORD volumes: - /data/pg:/var/lib/postgresql/dataPut POSTGRES_PASSWORD in .kamal/secrets. Listing it under both the accessory’s and the app’s env.secret delivers it to both containers.
2. Point the production datasource at the accessory. Each host has its own kamal Docker network. An app container on the same host as the accessory reaches it at the hostname <service>-<name> (myapp-db above). An app container on another host connects to the address port: binds (10.0.0.20:5432 above, a private interface the app hosts can reach); see the caution under the Redis example. Define the datasource in config/app.cfm, for production only, so local development keeps its SQLite datasource of the same name:
// Production: the `db` accessory. currentEnv is set by public/Application.cfc// from WHEELS_ENV before this file runs.if ((variables.currentEnv ?: "") == "production") { this.datasources["myapp"] = { class: "org.postgresql.Driver", bundleName: "org.postgresql.jdbc", // Same host as the accessory: myapp-db. Other hosts: 10.0.0.20. connectionString: "jdbc:postgresql://myapp-db:5432/myapp", username: "app", password: server.system.environment.POSTGRES_PASSWORD ?: "" };}Read the password from server.system.environment: in the container it arrives as a process environment variable, not through a .env file. The datasource name must match dataSourceName in config/settings.cfm (the app’s name by default, or WHEELS_DATASOURCE).
3. No driver step. The lucee/lucee 7.1 image that wheels deploy init uses already contains the PostgreSQL (42.7.9), MySQL (9.6.0) and SQL Server (13.2.1) JDBC drivers, so nothing has to be installed at boot. For MySQL, use class: "com.mysql.cj.jdbc.Driver", bundleName: "com.mysql.cj" and a jdbc:mysql://myapp-db:3306/myapp URL.
4. Migrations. set(autoMigrateDatabase=true) in config/production/settings.cfm runs pending migrations when the app starts. That is safe for one app host only: with several hosts sharing the database, every host migrates at boot at the same time. With more than one host, add migrate: to config/deploy.yml instead: wheels deploy then migrates on one host before the others boot, and turns migration at start off on every other host.
Boot the accessory before the first deploy (wheels deploy accessory boot db, or wheels deploy setup, which boots every accessory first).
Named containers and labels
Section titled “Named containers and labels”Accessory containers are named <service>-<accessory> — the example above yields myapp-db and myapp-redis. Their service= label uses that same combined value (service=myapp-db), not the app containers’ bare service=myapp — so a docker ps --filter label=service=myapp won’t catch them. wheels deploy details still lists them alongside everything else because it inspects each declared accessory container by name rather than relying on the shared label.
Multi-host accessories
Section titled “Multi-host accessories”Declare multiple hosts to run independent copies:
accessories: redis: image: redis:7 hosts: - 192.0.2.20 - 192.0.2.21Each host gets its own independent container. There is no clustering, no replication, no leader election — that’s the accessory’s job. If you need a Redis cluster, either configure it manually across the hosts or run it as a managed service.
Lifecycle verbs
Section titled “Lifecycle verbs”Every accessory has an independent lifecycle, scoped by name:
wheels deploy accessory boot db # first-time installwheels deploy accessory reboot db # stop + remove + bootwheels deploy accessory start db # docker startwheels deploy accessory stop db # docker stopwheels deploy accessory restart db # docker restartwheels deploy accessory details db # docker ps filteredwheels deploy accessory logs db --tail=100 # tail container logswheels deploy accessory remove db # stop + rmPass all instead of a specific name to fan out:
wheels deploy accessory boot allwheels deploy accessory stop allwheels deploy setup boots every declared accessory before it deploys the app, so a first run needs no separate wheels deploy accessory boot all. A plain wheels deploy does not boot accessories. wheels deploy remove tears them down.
Accessories and wheels deploy
Section titled “Accessories and wheels deploy”Accessories are deliberately not part of the rolling wheels deploy flow. Running wheels deploy does not restart Redis, does not upgrade Postgres, and does not reboot anything under accessories:. That separation is the whole point — app deploys happen ten times a day, accessory changes happen rarely, and mixing the two is how you accidentally take the database down during a routine rollout.
When you do want to change an accessory — a Redis version bump, a Postgres config reload — run the accessory verb explicitly.