CLI Reference
app & utilities
Application commands and utility functions.
app commands
Each of these starts a new container from the image the named role is currently serving — none of them attach to the container taking traffic. Nothing done inside reaches the running app, and the container is discarded on exit. The container gets the same environment a deploy would give it (env.clear and env.secret both), so rails db:migrate sees the same DATABASE_URL the app beside it does.
All three take --role ROLE (default: web) to pick which role's running image to use. A service with no web role needs --role on every host, since the default matches nothing:
odysseus app exec worker1.example.com --role jobs --command "rails runner …"
app shell
Open an interactive shell (/bin/sh) in a new container built from the image a role is serving.
odysseus app shell <server> [--role ROLE]
Example
odysseus app shell app.example.com
Before handing over the terminal, it prints a header — to stderr, so a captured session's stdout stays clean — naming the server, role, image and command:
App Shell
Server: app.example.com
Role: web
Image: myapp:a1b2c3d4
Command: /bin/sh
› New container from that image: the running app is untouched, and this one is discarded on exit.
# Now inside the new container
env | grep DATABASE
ls -la
exit
app exec
Run a command in a new container built from the image a role is serving. Unlike shell and console, this container isn't interactive, so it's for one-shot commands rather than a REPL — reach for console if you want one.
odysseus app exec <server> --command <cmd> [--role ROLE]
--command is required; without it, app exec exits with an error before connecting to anything.
Examples
# Database migration
odysseus app exec app.example.com --command "rails db:migrate"
# Check environment
odysseus app exec app.example.com --command "env | sort"
# List files
odysseus app exec app.example.com --command "ls -la /app"
# A non-web role
odysseus app exec worker1.example.com --role jobs --command "rails runner 'Job.retry_failed'"
app console
Start an interactive console in a new container built from the image a role is serving — the same kind of container shell opens, just running --cmd instead of a shell.
odysseus app console <server> [--cmd <command>] [--role ROLE]
Default command: /bin/sh. --cmd is a full command line, so quote it when it has arguments.
Examples
# Default shell
odysseus app console app.example.com
# Rails console
odysseus app console app.example.com --cmd "rails console"
# Django shell
odysseus app console app.example.com --cmd "python manage.py shell"
# Node.js REPL
odysseus app console app.example.com --cmd "node"
Utility commands
status
Show deployment status for a server.
odysseus status <server>
Example
odysseus status app.example.com
Output — one section per role family, each shown only when it applies:
Odysseus Status
Service: myapp
Server: app.example.com
▸ Web
Version Ref Deployed Image State Health
──────── ──── ──────────────────── ─────────────── ─────── ──────
a1b2c3d4 main 2026-08-14T09:02:11Z myapp:a1b2c3d4 running ✓
Proxy: app.example.com
Upstreams: myapp-a1b2c3d4-20260814090211:3000
▸ Workers
Role State Name Image
───── ─────── ─────────────────────────────────── ───────────────
jobs running myapp-jobs-a1b2c3d4-20260814090211 myapp:a1b2c3d4
▸ Dependencies
Name State Image Container Health
───── ─────── ───────────── ──────────── ──────
db running postgres:16 a1b2c3d4e5f6 ✓
redis stopped redis:7 -
▸ TLS
Domains: app.example.com
Issuer: acme
Email: (not set)
"Workers" only appears for roles other than web, and "Dependencies" only for a service that has any configured — this example has both, and one dependency stopped, to show the shape. "Version" and "Ref" come from the container's odysseus.version/odysseus.git_ref labels, so a container started outside odysseus shows (unlabelled) and - there instead. "Upstreams" is always a container-name:port dial odysseus wrote when it added the container to Caddy (web_deploy.rb:224-225), never an IP.
containers
List containers labelled with a service name on a server.
odysseus containers <server> [--service NAME]
Options
--service NAME
The odysseus.service label to filter by. Default is the bare service name, and only the web role is labelled with that — so with no --service, this lists web containers only. To see a worker role or a dependency, name its own service label explicitly (myapp-jobs, myapp-db, ...).
Example
odysseus containers app.example.com
odysseus containers app.example.com --service myapp-jobs
odysseus containers app.example.com --service myapp-db
Output:
Odysseus Containers
Server: app.example.com
ID State Name Image Status
──────────── ─────── ────────────────────────────── ────────────── ─────────
a1b2c3d4e5f6 running myapp-a1b2c3d4-20260814090211 myapp:a1b2c3d4 Up 2 days
Columns come straight from docker ps: ID, State, Name, Image and Status. Names carry no web segment — a web container is just service-version-timestamp (web_deploy.rb:120); a jobs one is service-jobs-version-timestamp (job_deploy.rb:98).
This only ever shows running containers. cli.rb:269 calls docker.list without all:, so it runs plain docker ps — no -a — and a stopped container never appears here, no matter how recently it exited. status, cleanup and logs all read with all: true; containers doesn't. There's no ports or created-at column either, and no combined view across roles — each --service value returns just that one label's containers.
logs
View application logs.
odysseus logs <server> [options]
Options
--role ROLE
Role to show logs for. Default: web
-f, --follow
Follow log output in real-time.
-n, --lines N
Number of lines to show. Default: 100.
--since TIME
Show logs since timestamp or duration.
Examples
# Web logs (last 100 lines)
odysseus logs app.example.com
# Follow web logs
odysseus logs app.example.com -f
# Job worker logs
odysseus logs app.example.com --role jobs
# Last 500 lines
odysseus logs app.example.com -n 500
# Logs from last hour
odysseus logs app.example.com --since 1h
Stopped containers are included in the search — docker ps without -a hides the container that has just exited, which is precisely the one whose logs you came for. What a stopped match here is not, though, is a previous version: a deploy stops and removes the container it replaces (web_deploy.rb:252-253), and the keep:2 sweep on old containers only ever looks at ones already in state exited (docker/client.rb:299-301) — so a stopped container found by logs means something exited on its own, which is exactly the case worth reading logs for. When the only match is stopped, logs says so before printing them; that notice, and the message when nothing matches at all, go to stderr, so odysseus logs web1 > app.log captures the log lines and nothing else:
! No running container for myapp: showing logs from exited container a1b2c3d4e5f6
Finding no container at all — running or stopped — is a failed request, not an empty success: logs exits non-zero, and the message names the role, the exact odysseus.service label it searched for, and the roles this config declares:
✗ No running or stopped container for role 'jobs' on app.example.com (nothing labelled odysseus.service=myapp-jobs)
› Name the role with --role. Roles in this config: web, jobs
cleanup
Remove every container for this service on a host, across every role and dependency — not just old ones.
odysseus cleanup <server> [--prune-images]
This removes the container currently serving traffic too
cleanup finds every container labelled with the service's name, each other role's derived name, and each dependency's — running or stopped — and stops and force-removes all of them, unconditionally. There's no "keep the last two" logic here; that only applies to the cleanup a deploy does for itself after starting its replacement. Running this against a host still serving the app takes it down. It also tears down that service's Caddy routes, and removes the Caddy container itself if no other service is still using it.
Options
--prune-images
Also prune dangling Docker images after removing containers.
Example
odysseus cleanup app.example.com
Output:
Odysseus Cleanup
Service: myapp
Server: app.example.com
› Removed 4 container(s)
› Caddy removed (no other services)
✓ Cleanup complete
If another service is still routed through the same Caddy instance, Caddy is left running instead:
› Caddy kept (1 other service(s))
With --prune-images, an extra step prunes dangling images (not tagged ones like latest) after the containers are gone:
› Pruning dangling images...
validate
Validate configuration file. This loads plugins:/sails: before checking anything else, the same as every other command, so it also catches a plugin gem that isn't installed on this machine.
odysseus validate [--config FILE]
Example
odysseus validate
Output (success):
Validating deploy.yml
✓ Configuration is valid
Service: myapp
Image: myapp
Servers: web, jobs
Proxy: app.example.com
Dependencies: db, redis
Only the Dependencies line is conditional — it's printed with an if config[:dependencies]&.any? guard (cli.rb:299) and omitted entirely on a config with none. Proxy has no such guard (cli.rb:298): it always prints, and parse_proxy defaults hosts to [] when it's missing (parser.rb:145-150), so a deploy.yml with no proxy.hosts shows an empty Proxy: line rather than no line at all.
Output (error) — the message is whatever the underlying config error says, so it's specific to what's actually wrong rather than a fixed format:
Validating deploy.yml
✗ Validation failed: Missing required keys: service, image
Common options
--config FILE
Path to configuration file. Default: deploy.yml. status, containers, logs, cleanup and validate all read it the same way; app's subcommands parse --config again for themselves.
odysseus status app.example.com --config production.yml
--verbose, -v
status, containers, logs, cleanup and validate all parse -v/--verbose without error, but none of them read it — it has no effect on any command documented on this page. app's subcommands don't even parse it: passing -v to app shell, app exec or app console is an unrecognized-option error. Reach for --debug instead — see Global options for the commands -v actually does something on.
odysseus status app.example.com --verbose # parses, changes nothing
odysseus status app.example.com --debug # this is the one that works
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Configuration failed to load, the target wasn't found, or the command errored |
This table covers status, containers, logs, cleanup, validate and app exec. app shell and app console don't fit it: they hand the terminal to an interactive ssh session and exit with that session's own status (interactive_commands.rb:190-195) — 130 if you Ctrl-C, 255 if ssh itself can't connect, or whatever code the command you ran inside exits with. There's no fixed 0/1 for either of them.