diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bafdde..f333da7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
 ### Changed
 - Use username/groupname of invoking user inside container (#153)
 - Ignore already existing UID/GIDs (#139)
+- Allow top-level `image` to be omitted in `.scuba.yml` (#158)
 
 ### Fixed
 - Fixed deprecation error with collections.Mapping (#156)
diff --git a/doc/yaml-reference.md b/doc/yaml-reference.md
index 920d210..548ebdb 100644
--- a/doc/yaml-reference.md
+++ b/doc/yaml-reference.md
@@ -10,7 +10,7 @@ other YAML file schemas, most options are controlled by top-level keys.
 
 ### `image`
 
-The `image` node *is required* and defines the Docker image from which Scuba
+The `image` node defines the Docker image from which Scuba
 containers are created.
 
 Example:
@@ -18,6 +18,10 @@ Example:
 image: debian:8.2
 ```
 
+The `image` node is usually necessary but, as of scuba 2.5, can be omitted for
+`.scuba.yml` files in which only the `aliases` are intended to be used.
+
+
 ### `environment`
 
 The optional `environment` node allows environment variables to be specified.
diff --git a/scuba/__main__.py b/scuba/__main__.py
index a18f585..8add106 100644
--- a/scuba/__main__.py
+++ b/scuba/__main__.py
@@ -279,13 +279,8 @@ class ScubaDive(object):
             self.add_option('--tty')
 
         # Process any aliases
-        try:
-            context = self.config.process_command(self.user_command)
-        except ConfigError as cfgerr:
-            raise ScubaError(str(cfgerr))
-
-        if self.image_override:
-            context.image = self.image_override
+        context = self.config.process_command(self.user_command,
+                image=self.image_override)
 
         '''
         Normally, if the user provides no command to "docker run", the image's
@@ -464,6 +459,9 @@ def main(argv=None):
     try:
         rc = run_scuba(scuba_args) or 0
         sys.exit(rc)
+    except ConfigError as e:
+        appmsg("Config error: " + str(e))
+        sys.exit(128)
     except DockerExecuteError as e:
         appmsg(str(e))
         sys.exit(2)
diff --git a/scuba/config.py b/scuba/config.py
index 660eb66..c1bba08 100644
--- a/scuba/config.py
+++ b/scuba/config.py
@@ -207,8 +207,8 @@ class ScubaContext(object):
 
 class ScubaConfig(object):
     def __init__(self, **data):
-        required_nodes = ('image',)
-        optional_nodes = ('aliases','hooks','entrypoint','environment')
+        required_nodes = ()
+        optional_nodes = ('image','aliases','hooks','entrypoint','environment')
 
         # Check for missing required nodes
         missing = [n for n in required_nodes if not n in data]
@@ -222,7 +222,7 @@ class ScubaConfig(object):
             raise ConfigError('{}: Unrecognized node{}: {}'.format(SCUBA_YML,
                     's' if len(extra) > 1 else '', ', '.join(extra)))
 
-        self._image = data['image']
+        self._image = data.get('image')
         self._entrypoint = _get_entrypoint(data)
         self._load_aliases(data)
         self._load_hooks(data)
@@ -255,6 +255,8 @@ class ScubaConfig(object):
 
     @property
     def image(self):
+        if not self._image:
+            raise ConfigError("Top-level 'image' not set")
         return self._image
 
     @property
@@ -274,11 +276,12 @@ class ScubaConfig(object):
         return self._environment
 
 
-    def process_command(self, command):
+    def process_command(self, command, image=None):
         '''Processes a user command using aliases
 
         Arguments:
             command     A user command list (e.g. argv)
+            image       Override the image from .scuba.yml
 
         Returns: A ScubaContext object with the following attributes:
             script: a list of command line strings
@@ -286,7 +289,7 @@ class ScubaConfig(object):
         '''
         result = ScubaContext()
         result.script = None
-        result.image = self.image
+        result.image = None
         result.entrypoint = self.entrypoint
         result.environment = self.environment.copy()
 
@@ -322,6 +325,15 @@ class ScubaConfig(object):
 
             result.script = flatten_list(result.script)
 
+        # If an image was given, it overrides what might have been set by an alias
+        if image:
+            result.image = image
+
+        # If the image was still not set, then try to get it from the confg,
+        # which will raise a ConfigError if it is not set
+        if not result.image:
+            result.image = self.image
+
         return result
 
 

Allow missing top-level 'image' from .scuba.yml
For some projects it may not make sense to have a top-level (default) `image`. For these projects, the `aliases` are the primary use of Scuba.

Allow `.scuba.yml` to omit a top-level `image` key, and in this case require than an alias is being run.
diff --git a/tests/test_config.py b/tests/test_config.py
index 9242d29..167aadf 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -111,12 +111,14 @@ class TestConfig(TmpDirTestCase):
         with self.assertRaises(scuba.config.ConfigError):
             scuba.config.load_config('.scuba.yml')
 
-    def test_load_config_empty(self):
-        '''load_config raises ConfigError if the config is empty'''
+    def test_load_config_no_image(self):
+        '''load_config raises ConfigError if the config is empty and image is referenced'''
         with open('.scuba.yml', 'w') as f:
             pass
 
-        self._test_invalid_config()
+        config = scuba.config.load_config('.scuba.yml')
+        with self.assertRaises(scuba.config.ConfigError):
+            img = config.image
 
     def test_load_unexpected_node(self):
         '''load_config raises ConfigError on unexpected config node'''
@@ -441,6 +443,42 @@ class TestConfig(TmpDirTestCase):
         assert_equal(result.entrypoint, '')
 
 
+    def test_process_command_image_override(self):
+        '''process_command allows image to be overridden when provided'''
+        override_image_name = 'override_image'
+
+        cfg = scuba.config.ScubaConfig(
+                image = 'test_image',
+                )
+        result = cfg.process_command([], image=override_image_name)
+        assert_equal(result.image, override_image_name)
+
+    def test_process_command_image_override_missing(self):
+        '''process_command allows image to be overridden when not provided'''
+        override_image_name = 'override_image'
+
+        cfg = scuba.config.ScubaConfig()
+        result = cfg.process_command([], image=override_image_name)
+        assert_equal(result.image, override_image_name)
+
+    def test_process_command_image_override_alias(self):
+        '''process_command allows image to be overridden when provided by alias'''
+        override_image_name = 'override_image'
+
+        cfg = scuba.config.ScubaConfig(
+                aliases = dict(
+                    apple = dict(
+                        script = [
+                            'banana cherry "pie is good"',
+                            'so is peach',
+                        ],
+                        image = 'apple_image',
+                    ),
+                )
+            )
+        result = cfg.process_command([], image=override_image_name)
+        assert_equal(result.image, override_image_name)
+
     ############################################################################
     # Hooks
 

pip install -e .
parse_log_pytest
requirements.txt
nose pytest
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
coverage==7.8.0
exceptiongroup==1.2.2
iniconfig==2.1.0
nose==1.3.7
packaging==24.2
pluggy==1.5.0
pytest==8.3.5
PyYAML==6.0.2
-e git+https://github.com/JonathonReinhart/scuba.git@fbfafaad8a80e5fc9d312ee53eb8846d998db68e#egg=scuba
tomli==2.2.1

name: scuba
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - nose==1.3.7
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - pyyaml==6.0.2
      - tomli==2.2.1
prefix: /opt/conda/envs/scuba

tests/test_config.py::TestConfig::test_load_config_no_image tests/test_config.py::TestConfig::test_process_command_image_override tests/test_config.py::TestConfig::test_process_command_image_override_alias tests/test_config.py::TestConfig::test_process_command_image_override_missing
tests/test_config.py::TestCommonScriptSchema::test_script_key_list tests/test_config.py::TestCommonScriptSchema::test_script_key_mapping_invalid tests/test_config.py::TestCommonScriptSchema::test_script_key_string tests/test_config.py::TestCommonScriptSchema::test_simple tests/test_config.py::TestConfig::test_alias_entrypoint tests/test_config.py::TestConfig::test_alias_entrypoint_empty_string tests/test_config.py::TestConfig::test_alias_entrypoint_null tests/test_config.py::TestConfig::test_entrypoint_emptry_string tests/test_config.py::TestConfig::test_entrypoint_not_set tests/test_config.py::TestConfig::test_entrypoint_null tests/test_config.py::TestConfig::test_entrypoint_set tests/test_config.py::TestConfig::test_env_alias tests/test_config.py::TestConfig::test_env_top_dict tests/test_config.py::TestConfig::test_env_top_list tests/test_config.py::TestConfig::test_find_config_cur_dir tests/test_config.py::TestConfig::test_find_config_nonexist tests/test_config.py::TestConfig::test_find_config_parent_dir tests/test_config.py::TestConfig::test_find_config_way_up tests/test_config.py::TestConfig::test_hooks_invalid_list tests/test_config.py::TestConfig::test_hooks_missing_script tests/test_config.py::TestConfig::test_hooks_mixed tests/test_config.py::TestConfig::test_load_config__no_spaces_in_aliases tests/test_config.py::TestConfig::test_load_config_from_yaml_cached_file tests/test_config.py::TestConfig::test_load_config_image_from_yaml tests/test_config.py::TestConfig::test_load_config_image_from_yaml_missing_arg tests/test_config.py::TestConfig::test_load_config_image_from_yaml_missing_file tests/test_config.py::TestConfig::test_load_config_image_from_yaml_nested_key_missing tests/test_config.py::TestConfig::test_load_config_image_from_yaml_nested_keys tests/test_config.py::TestConfig::test_load_config_image_from_yaml_nested_keys_with_escaped_characters tests/test_config.py::TestConfig::test_load_config_image_from_yaml_unicode_args tests/test_config.py::TestConfig::test_load_config_minimal tests/test_config.py::TestConfig::test_load_config_safe tests/test_config.py::TestConfig::test_load_config_safe_external tests/test_config.py::TestConfig::test_load_config_with_aliases tests/test_config.py::TestConfig::test_load_unexpected_node tests/test_config.py::TestConfig::test_process_command_alias_overrides_image tests/test_config.py::TestConfig::test_process_command_alias_overrides_image_and_empty_entrypoint tests/test_config.py::TestConfig::test_process_command_alias_overrides_image_and_entrypoint tests/test_config.py::TestConfig::test_process_command_aliases_unused tests/test_config.py::TestConfig::test_process_command_aliases_used_noargs tests/test_config.py::TestConfig::test_process_command_aliases_used_withargs tests/test_config.py::TestConfig::test_process_command_empty tests/test_config.py::TestConfig::test_process_command_image tests/test_config.py::TestConfig::test_process_command_multiline_aliases_forbid_user_args tests/test_config.py::TestConfig::test_process_command_multiline_aliases_used tests/test_config.py::TestConfig::test_process_command_no_aliases
scuba/config.py scuba/__main__.py doc/yaml-reference.md CHANGELOG.md
scuba/config.py scuba/__main__.py doc/yaml-reference.md CHANGELOG.md