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

CodeMeaning
0Success
1Configuration 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.

Previous
dependency