Skip to content

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

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.

config/deploy.yml (illustrative — do not type)
accessories:
redis:
image: redis:7
host: 192.0.2.20
port: "10.0.0.20:6379:6379" # bind address : host port : container port

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

config/deploy.yml (illustrative — do not type)
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/data
  • env.clear: values become docker run -e flags on the accessory container.
  • volumes: persists /var/lib/postgresql/data to the host so the database survives docker 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:

config/deploy.yml (illustrative — do not type)
service: myapp
env:
secret:
- POSTGRES_PASSWORD # the app needs it too
accessories:
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/data

Put 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:

config/app.cfm (illustrative — do not type)
// 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).

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.

Declare multiple hosts to run independent copies:

config/deploy.yml (illustrative — do not type)
accessories:
redis:
image: redis:7
hosts:
- 192.0.2.20
- 192.0.2.21

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

Every accessory has an independent lifecycle, scoped by name:

illustrative — do not type
wheels deploy accessory boot db # first-time install
wheels deploy accessory reboot db # stop + remove + boot
wheels deploy accessory start db # docker start
wheels deploy accessory stop db # docker stop
wheels deploy accessory restart db # docker restart
wheels deploy accessory details db # docker ps filtered
wheels deploy accessory logs db --tail=100 # tail container logs
wheels deploy accessory remove db # stop + rm

Pass all instead of a specific name to fan out:

illustrative — do not type
wheels deploy accessory boot all
wheels deploy accessory stop all

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