Repository: galaxyproject/gravity (language: python)

Issue:
!include: gravity.yml breaks Galaxy
Because the `!include` directive support is only implemented in Gravity, it works fine with Gravity, but when Galaxy tries to load `galaxy.yml`, it blows up. I implemented the include support but then didn't end up actually needing it so I guess that didn't get tested...

So we should either make Galaxy capable of parsing it (it doesn't actually need to perform the include), or we should drop support for it entirely. You could also imagine having separate Galaxy and Gravity configs, and adding a `galaxy_config_file` param to Gravity for locating the Galaxy config corresponding to a given Gravity config.


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index 96d0a37..efd6d1b 100644
--- a/README.rst
+++ b/README.rst
@@ -1,53 +1,123 @@
-========================================
- gravity - Galaxy Server Administration
-========================================
+============================================
+ gravity - Galaxy Server Process Management
+============================================
 
-A process manager (`supervisor`_) and management tools for `Galaxy`_ servers.
+Process management for `Galaxy`_ servers.
 
-Installing this will give you two executables, ``galaxyctl`` which is used to manage the starting, stopping, and logging
-of Galaxy's various processes, and ``galaxy``, which can be used to run a Galaxy server in the foreground.
+Modern Galaxy servers run multiple disparate processes: `gunicorn`_ for serving the web application, `celery`_ for
+asynchronous tasks, `tusd`_ for fault-tolerant uploads, standalone Galaxy processes for job handling, and more. Gravity
+is Galaxy's process manager, to make configuring and running these services simple.
+
+Installing Gravity will give you two executables, ``galaxyctl`` which is used to manage the starting, stopping, and
+logging of Galaxy's various processes, and ``galaxy``, which can be used to run a Galaxy server in the foreground.
 
 Installation
 ============
 
-Python 3.7 or later is required. Gravity can be installed independently of Galaxy, but please read the section on Galaxy
-Integration below.
+Python 3.7 or later is required. Gravity can be installed independently of Galaxy, but it is also a dependency of
+Galaxy since Galaxy 22.01. If you've installed Galaxy, then Gravity is already installed in Galaxy's virtualenv.
+
+To install independently:
 
-To install::
+.. code:: console
 
     $ pip install gravity
 
 To make your life easier, you are encourged to install into a `virtualenv`_. The easiest way to do this is with Python's
-built-in `venv`_ module::
+built-in `venv`_ module:
+
+.. code:: console
 
     $ python3 -m venv ~/gravity
     $ . ~/gravity/bin/activate
 
-By default, Gravity will store its state, configuration, and log files in ``$XDG_CONFIG_HOME/galaxy-gravity``, where
-``$XDG_CONFIG_HOME`` typically defaults to ``~/.config``. You can change this with the ``--state-dir`` option to the
-Gravity commands, or by setting ``$GRAVITY_STATE_DIR`` in your environment.
+Usage
+=====
 
-Galaxy 22.01 Integration
-========================
+Gravity needs to know where your Galaxy configuration file is, and depending on your Galaxy layout, some additional
+details like the paths to its virtualenv and root directory. Gravity's configuration is defined in Galaxy's
+configuration file (``galaxy.yml``) to be easy and familiar for Galaxy administrators.
 
-Gravity was originally designed to support managing multiple Galaxy (as well as Galaxy Reports and Tool Shed) servers on
-a single host, but hides some of this complexity from you if you are working with a single Galaxy server. Additionally,
-Galaxy 22.01 has added Gravity as a dependency, and changes have been made to Gravity to support this mode of operation.
+Examples in this documentation assume a Galaxy layout like the one used in the `Galaxy Installation with Ansible`_
+tutorial::
 
-- Gravity 0.9.0 has dropped support for running Galaxy with uWSGI in favor of `gunicorn`_ and `FastAPI`_.
-- Gravity 0.9.0 cannot be used with Galaxy versions older than 22.01.
-- As of Galaxy 22.01, Gravity is automatically installed into Galaxy's virtualenv by Galaxy's setup scripts (as called
-  by ``run.sh``)
-- Galaxy's setup scripts as of 22.01 also set ``$GRAVITY_STATE_DIR`` to ``<galaxy_root>/database/gravity`` when running
-  Galaxy from the source directory. This keeps each Galaxy instance's Gravity configuration separate.
+    /srv/galaxy/server  # Galaxy code
+    /srv/galaxy/config  # config files
+    /srv/galaxy/venv    # virtualenv
 
-Usage
-=====
+Gravity can either run Galaxy via the `supervisor`_ process manager (the default) or `systemd`_.
+
+Managing a Single Galaxy
+------------------------
+
+The simplest way to use Gravity is to activate Galaxy's virtualenv, which will put Gravity's ``galaxyctl`` and
+``galaxy`` commands on your ``$PATH``:
+
+.. code:: console
+
+    $ . /srv/galaxy/venv/bin/activate
+    $ galaxyctl --help
+    Usage: galaxyctl [OPTIONS] COMMAND [ARGS]...
+
+      Manage Galaxy server configurations and processes.
+
+    Options:
+      -d, --debug             Enables debug mode.
+      -c, --config-file FILE  Gravity (or Galaxy) config file to operate on. Can
+                              also be set with $GRAVITY_CONFIG_FILE or
+                              $GALAXY_CONFIG_FILE
+      --state-dir DIRECTORY   Where process management configs and state will be
+                              stored.
+      -h, --help              Show this message and exit.
 
-If running from the root of a Galaxy source tree, you can start and run Galaxy in the foreground with::
+    Commands:
+      configs     List registered config files.
+      deregister  Deregister config file(s).
+      exec        Run a single Galaxy service in the foreground, with logging...
+      follow      Follow log files of configured instances.
+      graceful    Gracefully reload configured services.
+      instances   List all known instances.
+      pm          Invoke process manager (supervisorctl, systemctl) directly.
+      register    Register config file(s).
+      rename      Update path of registered config file.
+      restart     Restart configured services.
+      show        Show details of registered config.
+      shutdown    Shut down process manager.
+      start       Start configured services.
+      status      Display server status.
+      stop        Stop configured services.
+      update      Update process manager from config changes.
+
+If you run ``galaxy`` or ``galaxyctl`` from the root of a Galaxy source checkout and do not specify the config file
+option, ``config/galaxy.yml`` or ``config/galaxy.yml.sample`` will be automatically used. This is handy for working with
+local clones of Galaxy for testing or development. You can skip Galaxy's lengthy and repetitive ``run.sh`` configuration
+steps when starting and stopping Galaxy in between code updates (you should still run ``run.sh`` after performing a
+``git pull`` to make sure your dependencies are up to date).
+
+For production servers, **it is recommended that you run Gravity as root in systemd mode**. See the :ref:`Managing a
+Production Galaxy` section below for details.
+
+To avoid having to run from the galaxy root directory, you can explicitly point Gravity at your Galaxy configuration
+file with the ``--config-file`` option or ``$GRAVITY_CONFIG_FILE`` (or ``$GALAXY_CONFIG_FILE``, as set by Galaxy's
+``run.sh`` script) environment variable. Then it's possible to run the ``galaxyctl`` command from anywhere.
+
+.. code:: console
+
+    $ galaxyctl --config-file /srv/galaxy/config/galaxy.yml SUBCOMMAND [OPTIONS]
+
+Often times it's more convenient to put the environment variable in the Galaxy user's shell environment file, e.g.:
+
+.. code:: console
+
+    $ echo "export GRAVITY_CONFIG_FILE='/srv/galaxy/config/galaxy.yml'" >> ~/.bash_profile
+
+Once you have configured the path to your config file, you can start and run Galaxy in the foreground using the
+``galaxy`` command:
+
+.. code:: console
 
     $ galaxy
-    Registered galaxy config: /home/nate/work/galaxy/config/galaxy.yml
+    Registered galaxy config: /srv/galaxy/config/galaxy.yml
     Creating or updating service gunicorn
     Creating or updating service celery
     Creating or updating service celery-beat
@@ -60,19 +130,20 @@ If running from the root of a Galaxy source tree, you can start and run Galaxy i
     celery                           STARTING
     celery-beat                      STARTING
     gunicorn                         STARTING
-    ==> /home/nate/work/galaxy/database/gravity/log/gunicorn.log <==
+    ==> /srv/galaxy/var/gravity/log/gunicorn.log <==
     ...log output follows...
 
 Galaxy will continue to run and output logs to stdout until terminated with ``CTRL+C``.
 
-The ``galaxy`` command is actually a shortcut for three separate steps: 1. register your Galaxy configuration file
-(``galaxy.yml``) with Gravity, 2. write out the process manager configurations, and 3. start and run Galaxy in the
-foreground using the process manager (`supervisor`_). You can perform these steps separately (and in this example, start
-Galaxy as a backgrounded daemon instead of in the foreground)::
+The ``galaxy`` command is actually a shortcut for two separate steps: 1. read the provided ``galaxy.yml`` and write out
+the corresponding process manager configurations, and 2. start and run Galaxy in the foreground using the process
+manager (`supervisor`_). You can perform these steps separately (and in this example, start Galaxy as a backgrounded
+daemon instead of in the foreground):
+
+.. code:: console
 
-    $ galaxyctl register config/galaxy.yml
-    Registered galaxy config: /home/nate/work/galaxy/config/galaxy.yml
     $ galaxyctl update
+    Registered galaxy config: /home/nate/work/galaxy/config/galaxy.yml
     Creating or updating service gunicorn
     Creating or updating service celery
     Creating or updating service celery-beat
@@ -82,7 +153,9 @@ Galaxy as a backgrounded daemon instead of in the foreground)::
     gunicorn                         STARTING
     Log files are in /home/nate/work/galaxy/database/gravity/log
 
-When running as a daemon, the ``stop`` subcommand stops your Galaxy server::
+When running as a daemon, the ``stop`` subcommand stops your Galaxy server:
+
+.. code:: console
 
     $ galaxyctl stop
     celery-beat: stopped
@@ -91,29 +164,224 @@ When running as a daemon, the ``stop`` subcommand stops your Galaxy server::
     All processes stopped, supervisord will exit
     Shut down
 
-Once a Galaxy configuration file has been registered with Gravity, it doesn't matter where you call ``galaxy`` or
-``galaxyctl`` from.
+Managing a Production Galaxy
+----------------------------
+
+By default, Gravity runs Galaxy processes under `supervisor`_, but setting the ``process_manager`` option to ``systemd``
+in Gravity's configuration will cause it to run under `systemd`_ instead. systemd is the default init system under most
+modern Linux distributions, and using systemd is strongly encouraged for production Galaxy deployments.
+
+Gravity manages `systemd service unit files`_ corresponding to all of the Galaxy services that it is aware of, much like
+how it manages supervisor program config files in supervisor mode. If you run ``galaxyctl update`` as a non-root user,
+the unit files will be installed in ``~/.config/systemd/user`` and run via `systemd user mode`_. This can be useful for
+testing and development, but in production it is recommended to run Gravity as root, so that it installs the service
+units in ``/etc/systemd/system`` and are managed by the privileged systemd instance. Even when Gravity is run as root,
+Galaxy itself still runs as a non-root user, specified by the ``galaxy_user`` option in the Gravity configuration.
+
+It is also recommended, when running as root, that you install Gravity independent of Galaxy, rather than use the copy
+installed in Galaxy's virtualenv:
+
+.. code:: console
+
+    # python3 -m venv /opt/gravity
+    # /opt/gravity/bin/pip install gravity
+
+.. caution::
+
+    Because systemd unit file names have semantic meaning (the filename is the service's name) and systemd does not have
+    a facility for isolating unit files controlled by an application, Gravity considers all unit files in the unit dir
+    (``/etc/systemd/system``) that are named like ``galaxy-*`` to be controlled by Gravity. **If you have existing unit
+    files that are named as such, Gravity will overwrite or remove them.**
+
+In systemd mode, and especially when run as root, some Gravity options are required:
+
+.. code:: yaml
+
+    gravity:
+      process_manager: systemd
+
+      # required if running as root
+      galaxy_user: GALAXY-USERNAME
+      # optional, defaults to primary group of the user set above
+      galaxy_group: GALAXY-GROUPNAME
+
+      # required
+      virtualenv: /srv/galaxy/venv
+      # probably necessary if your galaxy.yml is not in galaxy_root/config
+      galaxy_root: /srv/galaxy/server
+
+See the :ref:`Configuration` section for more details on these options and others.
+
+When running Gravity as root, the following configuration files will automatically be searched for and read, unless
+``--config-file`` is specified or ``$GRAVITY_CONFIG_FILE`` is set:
+
+- ``/etc/galaxy/gravity.yml``
+- ``/etc/galaxy/galaxy.yml``
+- ``/etc/galaxy/gravity.d/*.y(a?)ml``
+
+It is *not* necessary to write your entire Galaxy configuration to the Gravity config file. You can write only the
+Gravity configuration, and then point to your Galaxy config file with the ``galaxy_config_file`` option in the Gravity
+config. See the :ref:`Managing Multiple Galaxies` section for more details.
+
+The ``log_dir`` option is ignored when using systemd. Logs are instead captured by systemd's logging facility,
+``journald``.
+
+You can use ``galaxyctl`` to manage Galaxy process starts/stops/restarts/etc. and follow the logs, just as you do under
+supervisor, but you can also use ``systemctl`` and ``journalctl`` directly to manage process states and inspect logs
+(respectively). Only ``galaxyctl update`` is necessary, in order to write and/or remove the appropriate systemd service
+units based on your configuration. For example:
+
+.. code:: console
+
+   # export GRAVITY_CONFIG_FILE=/srv/galaxy/config/galaxy.yml
+   # . /srv/galaxy/venv/bin/activate
+   (venv) # galaxyctl update
+   Adding service galaxy-gunicorn.service
+   Adding service galaxy-celery.service
+   Adding service galaxy-celery-beat.service
+
+After this point, operations can be performed with either ``galaxyctl`` or ``systemctl``. Some examples of equivalent
+commands:
+
+=================================== ==================================================================
+ Gravity                             systemd
+=================================== ==================================================================
+``galaxy``                          ``systemctl start galaxy.target && journalctl -f -u 'galaxy-*'``
+``galaxyctl start``                 ``systemctl start galaxy.target``
+``galaxyctl start SERVICE ...``     ``systemctl start galaxy-SERVICE.service galaxy-...``
+``galaxyctl restart``               ``systemctl restart galaxy.target``
+``galaxyctl restart SERVICE ...``   ``systemctl restart galaxy-SERVICE.service galaxy-...``
+``galaxyctl graceful``              ``systemctl reload-or-restart galaxy.target``
+``galaxyctl graceful SERVICE ...``  ``systemctl reload-or-restart galaxy-SERVICE.service galaxy-...``
+``galaxyctl stop``                  ``systemctl start galaxy.target``
+``galayxctl follow``                ``journalctl -f -u 'galaxy-*'``
+=================================== ==================================================================
+
+Managing Multiple Galaxies
+--------------------------
+
+Gravity can manage multiple instances of Galaxy simultaneously. This is useful especially in the case where you have
+multiple production Galaxy instances on a single server and are managing them with Gravity installed outside of a Galaxy
+virtualenv, as root. There are multiple ways to achieve this:
+
+1. Pass multiple ``--config-file`` options to ``galaxyctl``, or set a list of colon-separated config paths in
+   ``$GRAVITY_CONFIG_FILE``:
+
+    .. code:: console
+
+        $ galaxyctl --config-file /srv/galaxy/test/config/galaxy.yml \
+                    --config-file /srv/galaxy/main/config/galaxy.yml list --version
+        TYPE      INSTANCE NAME       VERSION       CONFIG PATH
+        galaxy    test                22.05         /srv/galaxy/test/config/galaxy.yml
+        galaxy    main                22.09.dev0    /srv/galaxy/main/config/galaxy.yml
+        $ export GRAVITY_CONFIG_FILE='/srv/galaxy/test/config/galaxy.yml:/srv/galaxy/main/config/galaxy.yml'
+        $ galaxyctl list --version
+        TYPE      INSTANCE NAME       VERSION       CONFIG PATH
+        galaxy    test                22.05         /srv/galaxy/test/config/galaxy.yml
+        galaxy    main                22.09.dev0    /srv/galaxy/main/config/galaxy.yml
+
+2. If running as root, any config files located in ``/etc/galaxy/gravity.d`` will automatically be loaded.
+
+3. Specify multiple Gravity configurations in a single config file, as a list. In this case, the Galaxy and Gravity
+   configurations must be in separate files as described in :ref:`Splitting Gravity and Galaxy Configurations`:
+
+    .. code:: yaml
+
+        gravity:
+          - instance_name: test
+            process_manager: systemd
+            galaxy_config_file: /srv/galaxy/test/config/galaxy.yml
+            galaxy_root: /srv/galaxy/test/server
+            virtualenv: /srv/galaxy/test/venv
+            galaxy_user: gxtest
+            gunicorn:
+              bind: unix:/srv/galaxy/test/var/gunicorn.sock
+            handlers:
+              handler:
+                pools:
+                  - job-handlers
+                  - workflow-schedulers
+
+          - instance_name: main
+            process_manager: systemd
+            galaxy_config_file: /srv/galaxy/main/config/galaxy.yml
+            galaxy_root: /srv/galaxy/main/server
+            virtualenv: /srv/galaxy/main/venv
+            galaxy_user: gxmain
+            gunicorn:
+              bind: unix:/srv/galaxy/main/var/gunicorn.sock
+              workers: 8
+            handlers:
+              handler:
+                processes: 4
+                pools:
+                  - job-handlers
+                  - workflow-schedulers
+
+In all cases, when using multiple Gravity instances, each Galaxy instance managed by Gravity must have a unique
+**instance name**. When working with a single instance, the default name ``_default_`` is used automatically and mostly
+hidden from you. When working with multiple instances, set the ``instance_name`` option in each instance's Gravity
+config to a unique name.
+
+Although it is strongly encouraged to use systemd for running multiple instances, it is possible to use supervisor. If
+using supervisor, the supervisor configurations will be stored in ``$XDG_CONFIG_HOME/galaxy-gravity``
+(``$XDG_CONFIG_HOME`` defaults to ``~/.config/galaxy-gravity``), so you may want to set this to a different path using
+the ``--state-dir`` option (or ``$GRAVITY_STATE_DIR``).
+
+Note, Galaxy 22.01 and 22.05 automatically set ``$GRAVITY_STATE_DIR`` to ``<galaxy_root>/database/gravity`` in the
+virtualenv's activation script.
 
 Configuration
 =============
 
-The following options in the ``gravity`` section of ``galaxy.yml`` can be used to control Gravity::
-unset are shown)::
+The following options in the ``gravity`` section of ``galaxy.yml`` can be used to control Gravity:
+
+.. code:: yaml
 
   # Configuration for Gravity process manager.
   # ``uwsgi:`` section will be ignored if Galaxy is started via Gravity commands (e.g ``./run.sh``, ``galaxy`` or ``galaxyctl``).
   gravity:
 
+    # Process manager to use.
+    # ``supervisor`` is the default process manager.
+    # ``systemd`` is also supported.
+    # Valid options are: supervisor, systemd
+    # process_manager: supervisor
+
+    # What command to write to the process manager configs
+    # `gravity` (`galaxyctl exec <service-name>`) is the default
+    # `direct` (each service's actual command) is also supported.
+    # Valid options are: gravity, direct
+    # service_command_style: gravity
+
+    # Memory limit (in GB), processes exceeding the limit will be killed. Default is no limit. If set, this is default value
+    # for all services. Setting ``memory_limit`` on an individual service overrides this value. Ignored if ``process_manager``
+    # is ``supervisor``.
+    # memory_limit:
+
+    # Specify Galaxy config file (galaxy.yml), if the Gravity config is separate from the Galaxy config. Assumed to be the
+    # same file as the Gravity config if a ``galaxy`` key exists at the root level, otherwise, this option is required.
+    # galaxy_config_file:
+
     # Specify Galaxy's root directory.
     # Gravity will attempt to find the root directory, but you can set the directory explicitly with this option.
     # galaxy_root:
 
+    # User to run Galaxy as, required when using the systemd process manager as root.
+    # Ignored if ``process_manager`` is ``supervisor`` or user-mode (non-root) ``systemd``.
+    # galaxy_user:
+
+    # Group to run Galaxy as, optional when using the systemd process manager as root.
+    # Ignored if ``process_manager`` is ``supervisor`` or user-mode (non-root) ``systemd``.
+    # galaxy_group:
+
     # Set to a directory that should contain log files for the processes controlled by Gravity.
-    # If not specified defaults to ``<state_dir>/log``.
+    # If not specified defaults to ``<galaxy_data_dir>/gravity/log``.
     # log_dir:
 
     # Set to Galaxy's virtualenv directory.
-    # If not specified, Gravity assumes all processes are on PATH.
+    # If not specified, Gravity assumes all processes are on PATH. This option is required in most circumstances when using
+    # the ``systemd`` process manager.
     # virtualenv:
 
     # Select the application server.
@@ -154,6 +422,17 @@ unset are shown)::
       # Consumes less memory when multiple processes are configured. Default is ``false`` if using unicornherder, else ``true``.
       # preload:
 
+      # Value of supervisor startsecs, systemd TimeoutStartSec
+      # start_timeout: 15
+
+      # Value of supervisor stopwaitsecs, systemd TimeoutStopSec
+      # stop_timeout: 65
+
+      # Memory limit (in GB). If the service exceeds the limit, it will be killed. Default is no limit or the value of the
+      # ``memory_limit`` setting at the top level of the Gravity configuration, if set. Ignored if ``process_manager`` is
+      # ``supervisor``.
+      # memory_limit:
+
       # Extra environment variables and their values to set when running the service. A dictionary where keys are the variable
       # names.
       # environment: {}
@@ -184,6 +463,17 @@ unset are shown)::
       # Extra arguments to pass to Celery command line.
       # extra_args:
 
+      # Value of supervisor startsecs, systemd TimeoutStartSec
+      # start_timeout: 10
+
+      # Value of supervisor stopwaitsecs, systemd TimeoutStopSec
+      # stop_timeout: 10
+
+      # Memory limit (in GB). If the service exceeds the limit, it will be killed. Default is no limit or the value of the
+      # ``memory_limit`` setting at the top level of the Gravity configuration, if set. Ignored if ``process_manager`` is
+      # ``supervisor``.
+      # memory_limit:
+
       # Extra environment variables and their values to set when running the service. A dictionary where keys are the variable
       # names.
       # environment: {}
@@ -219,6 +509,17 @@ unset are shown)::
       # This is an advanced option that is only needed when proxying to remote interactive tool container that cannot be reached through the local network.
       # reverse_proxy: false
 
+      # Value of supervisor startsecs, systemd TimeoutStartSec
+      # start_timeout: 10
+
+      # Value of supervisor stopwaitsecs, systemd TimeoutStopSec
+      # stop_timeout: 10
+
+      # Memory limit (in GB). If the service exceeds the limit, it will be killed. Default is no limit or the value of the
+      # ``memory_limit`` setting at the top level of the Gravity configuration, if set. Ignored if ``process_manager`` is
+      # ``supervisor``.
+      # memory_limit:
+
       # Extra environment variables and their values to set when running the service. A dictionary where keys are the variable
       # names.
       # environment: {}
@@ -259,6 +560,66 @@ unset are shown)::
       # Extra arguments to pass to tusd command line.
       # extra_args:
 
+      # Value of supervisor startsecs, systemd TimeoutStartSec
+      # start_timeout: 10
+
+      # Value of supervisor stopwaitsecs, systemd TimeoutStopSec
+      # stop_timeout: 10
+
+      # Memory limit (in GB). If the service exceeds the limit, it will be killed. Default is no limit or the value of the
+      # ``memory_limit`` setting at the top level of the Gravity configuration, if set. Ignored if ``process_manager`` is
+      # ``supervisor``.
+      # memory_limit:
+
+      # Extra environment variables and their values to set when running the service. A dictionary where keys are the variable
+      # names.
+      # environment: {}
+
+    # Configuration for Galaxy Reports.
+    reports:
+
+      # Enable Galaxy Reports server.
+      # enable: false
+
+      # Path to reports.yml, relative to galaxy.yml if not absolute
+      # config_file: reports.yml
+
+      # The socket to bind. A string of the form: ``HOST``, ``HOST:PORT``, ``unix:PATH``, ``fd://FD``. An IP is a valid HOST.
+      # bind: localhost:9001
+
+      # Controls the number of Galaxy Reports application processes Gunicorn will spawn.
+      # It is not generally necessary to increase this for the low-traffic Reports server.
+      # workers: 1
+
+      # Gunicorn workers silent for more than this many seconds are killed and restarted.
+      # Value is a positive number or 0. Setting it to 0 has the effect of infinite timeouts by disabling timeouts for all workers entirely.
+      # timeout: 300
+
+      # URL prefix to serve from.
+      # The corresponding nginx configuration is (replace <url_prefix> and <bind> with the values from these options):
+      #
+      # location /<url_prefix>/ {
+      #     proxy_pass http://<bind>/;
+      # }
+      #
+      # If <bind> is a unix socket, you will need a ``:`` after the socket path but before the trailing slash like so:
+      #     proxy_pass http://unix:/run/reports.sock:/;
+      # url_prefix:
+
+      # Extra arguments to pass to Gunicorn command line.
+      # extra_args:
+
+      # Value of supervisor startsecs, systemd TimeoutStartSec
+      # start_timeout: 10
+
+      # Value of supervisor stopwaitsecs, systemd TimeoutStopSec
+      # stop_timeout: 10
+
+      # Memory limit (in GB). If the service exceeds the limit, it will be killed. Default is no limit or the value of the
+      # ``memory_limit`` setting at the top level of the Gravity configuration, if set. Ignored if ``process_manager`` is
+      # ``supervisor``.
+      # memory_limit:
+
       # Extra environment variables and their values to set when running the service. A dictionary where keys are the variable
       # names.
       # environment: {}
@@ -267,39 +628,40 @@ unset are shown)::
     # See https://docs.galaxyproject.org/en/latest/admin/scaling.html#dynamically-defined-handlers for details.
     # handlers: {}
 
+Splitting Gravity and Galaxy Configurations
+-------------------------------------------
 
 As a convenience for cases where you may want to have different Gravity configurations but a single Galaxy
 configuration (e.g. your Galaxy server is split across multiple hosts), the Gravity configuration can be stored in a
-separate file and included into the Galaxy configuration. For example, on a deployment where the web (gunicorn) and job
-handler processes run on different hosts, one might have:
-
-In ``galaxy.yml``::
+separate file. In this case, you must set the ``galaxy_config_file`` option in the Gravity config to specify the
+location of the Galaxy config file.
 
-    gravity: !include gravity.yml
-    galaxy:
-      database_connection: postgresql://...
-      ...
+For example, on a deployment where the web (gunicorn) and job handler processes run on different hosts, one might have:
 
 In ``gravity.yml`` on the web host::
 
-    log_dir: /var/log/galaxy
-    gunicorn:
-      bind: localhost:8888
-    celery:
-      enable: false
-      enable_beat: false
+    gravity:
+      galaxy_config_file: galaxy.yml
+      log_dir: /var/log/galaxy
+      gunicorn:
+        bind: localhost:8888
+      celery:
+        enable: false
+        enable_beat: false
 
 In ``gravity.yml`` on the job handler host::
 
-    log_dir: /var/log/galaxy
-    gunicorn:
-      enable: false
-    celery:
-      enable: true
-      enable_beat: true
-    handlers:
-      handler:
-        processes: 2
+    gravity:
+      galaxy_config_file: galaxy.yml
+      log_dir: /var/log/galaxy
+      gunicorn:
+        enable: false
+      celery:
+        enable: true
+        enable_beat: true
+      handlers:
+        handler:
+          processes: 2
 
 Galaxy Job Handlers
 -------------------
@@ -312,39 +674,22 @@ Gravity has support for reading Galaxy's job configuration: it can read statical
 When using dynamically defined handlers, be sure to explicitly set the `job handler assignment method`_ to
 ``db-skip-locked`` or ``db-transaction-isolation`` to prevent the web process from also handling jobs.
 
-Configuration Precedence
-------------------------
+Configuration and State
+-----------------------
 
-Gravity's configuration is defined in Galaxy's configuration file to be easy and familiar for Galaxy administrators, but
-Gravity maintains its own state in ``$GRAVITY_STATE_DIR/configstate.yaml``.  **If set**, the options in ``galaxy.yml``
-will override Gravity's saved state whenever ``galaxyctl update`` is run, but if later **unset**, then the persisted
-values in Gravity's saved state are used.
+Older versions of Gravity stored a considerable amount of *config state* in ``$GRAVITY_STATE_DIR/configstate.yaml``. As
+of version 1.0.0, Gravity does not store state information, and this file can be removed.
 
-The exception is the values of ``app_server`` and ``job_handler_*``, which will revert to default values if unset in
-``galaxy.yml``, because Gravity dynamically adds and removes services based on the Galaxy configuration by design.
+Although Gravity no longer uses the config state file, it does still use a state directory for storing supervisor
+configs, the default log directory (if ``log_dir`` is unchanged), and the celery-beat database. This directory defaults
+to ``<galaxy_root>/database/gravity/`` by way of the ``data_dir`` option in the ``galaxy`` section of ``galaxy.yml``
+(which defaults to ``<galaxy_root>/database/``).
 
 Subcommands
 ===========
 
 Use ``galaxyctl --help`` for help. Subcommands also support ``--help``, e.g. ``galaxy register --help``
 
-register
---------
-
-Register a Galaxy server config (``galaxy.yml``) with Gravity. Does not update or start. Run ``galaxyctl update`` after
-registering to apply changes.
-
-list
-----
-
-List config files registered with the process manager.
-
-deregister
-----------
-
-Deregister a Galaxy server config, Gravity will no longer manage this Galaxy instance. Run ``galaxyctl update`` after
-deregistering to apply changes.
-
 start
 -----
 
@@ -384,84 +729,61 @@ update
 Figure out what has changed in configs, which could be:
 
 -  changes to the Gravity configuration options in ``galaxy.yml``
--  adding or removing handlers in ``job_conf.xml``
+-  adding or removing handlers in ``job_conf.yml`` or ``job_conf.xml``
 
 This may cause service restarts if there are any changes.
 
-Any needed changes to supervisor configs will be performed and then ``supervisorctl update`` will be called.
-
-``update`` is called automatically for the ``start``, ``stop``, ``restart``, and ``graceful`` subcommands.
+Any needed changes to supervisor or systemd configs will be performed and then ``supervisorctl update`` or ``systemctl
+daemon-reload`` will be called.
 
 shutdown
 --------
 
 Stop all processes and cause ``supervisord`` to terminate. Similar to ``stop`` but there is no ambiguity as to whether
-``supervisord`` remains running.
+``supervisord`` remains running. The equivalent of ``stop`` when using systemd.
 
-supervisorctl
--------------
+follow
+------
 
-Pass through directly to supervisor. Run ``galaxyctl supervisorctl`` to invoke the supervisorctl shell, or ``galaxyctl
-supervisorctl [command]`` to call a supervisorctl command directly. See the `supervisor`_ documentation or ``galaxyctl
-supervisorctl help`` for help.
+Follow (e.g. using ``tail -f`` (supervisor) or ``journalctl -f`` (systemd)) log files of all Galaxy services, or a
+subset (if named as arguments).
 
-instances
----------
+list
+----
 
-List known (configured) Galaxy instances and services.
+List config files known to Gravity.
 
 show
 ----
 
-Show stored configuration details for the named config file.
-
-rename
-------
-
-If your ``galaxy.yml`` has moved, you can update its path in Gravity's saved state with this command.
+Show Gravity configuration details for a Galaxy instance.
 
-configstate.yaml
-================
+pm
+--
 
-As discussed in the Configuration section, Gravity maintains a state file that also acts as a configuration of sorts.
-Administrators deploying Galaxy with a deployment tool (e.g. `Ansible`_) can take advantage of this to deploy a Gravity
-state file as part of their Galaxy deployment. See the ``$GRAVITY_STATE_DIR/configstate.yaml`` file after performing a
-``register`` and ``update`` command to see what this file looks like, or below for an example. Keep in mind that running
-``galaxyctl`` commands after changing the Galaxy configuration can cause changes to the state file because it was not
-originally intended to be user-maintainable. See `Issue #6`_ for discussion and development related to this, as we seek
-to provide a more consistent experience in working with Gravity's configuration.
+Pass through directly to the process manager (e.g. supervisor). Run ``galaxyctl pm`` to invoke the supervisorctl shell,
+or ``galaxyctl pm [command]`` to call a supervisorctl or systemctl command directly. See the `supervisor`_ documentation
+or ``galaxyctl pm help`` for help.
 
-Example
--------
+exec
+----
 
-A ``configstate.yaml`` file for a Galaxy service might look like::
-
-    config_files:
-      /home/nate/work/galaxy/config/galaxy.yml:
-        config_type: galaxy
-        instance_name: _default_
-        attribs:
-          app_server: gunicorn
-          log_dir: /home/nate/work/galaxy/database/gravity/log
-          bind: 'localhost:8080'
-          galaxy_root: /home/nate/work/galaxy
-        services:
-        - config_type: galaxy
-          service_type: gunicorn
-          service_name: gunicorn
-        - config_type: galaxy
-          service_type: celery
-          service_name: celery
-        - config_type: galaxy
-          service_type: celery-beat
-          service_name: celery-beat
+Directly execute a single Galaxy service in the foreground, e.g. ``galaxyctl exec gunicorn``, ``galaxyctl exec tusd``,
+etc. ``galaxyctl exec`` comands are written to the supervisor/systemd service files rather than the underlying command
+so that it is not necesary to rewrite the process manager configs and update the process manager every time a parameter
+is changed.
 
-.. _supervisor: http://supervisord.org/
 .. _Galaxy: http://galaxyproject.org/
+.. _gunicorn: https://gunicorn.org/
+.. _celery: https://docs.celeryq.dev/
+.. _tusd: https://tus.io/
+.. _supervisor: http://supervisord.org/
+.. _systemd: https://www.freedesktop.org/wiki/Software/systemd/
+.. _systemd service unit files: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
+.. _systemd user mode: https://www.freedesktop.org/software/systemd/man/user@.service.html
 .. _virtualenv: https://virtualenv.pypa.io/
 .. _venv: https://docs.python.org/3/library/venv.html
-.. _gunicorn: https://gunicorn.org/
-.. _FastAPI: https://fastapi.tiangolo.com/
+.. _Galaxy Installation with Ansible: https://training.galaxyproject.org/training-material/topics/admin/tutorials/ansible-galaxy/tutorial.html
 .. _unicornherder: https://github.com/alphagov/unicornherder
 .. _job handler assignment method: https://docs.galaxyproject.org/en/master/admin/scaling.html#job-handler-assignment-methods
 .. _dynamically defined handlers: https://docs.galaxyproject.org/en/latest/admin/scaling.html#dynamically-defined-handlers
diff --git a/docs/conf.py b/docs/conf.py
index e618380..ddd6c23 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -22,7 +22,7 @@
 
 master_doc = 'index'
 
-extensions = []
+extensions = ['sphinx.ext.autosectionlabel']
 
 templates_path = ['_templates']
 exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
diff --git a/gravity/cli.py b/gravity/cli.py
index 8e83ba3..ee8f24e 100644
--- a/gravity/cli.py
+++ b/gravity/cli.py
@@ -9,18 +9,13 @@
 from gravity import options
 
 
-# FIXME: -p/--python-exe unimplemented
 CONTEXT_SETTINGS = {
     "auto_envvar_prefix": "GRAVITY",
     "help_option_names": ["-h", "--help"]
 }
 
-# FIXME: incomplete aliases
 COMMAND_ALIASES = {
-    "list": "configs",
-    "add": "register",
-    "remove": "deregister",
-    "forget": "deregister",
+    "configs": "list",
     "get": "show",
     "reload": "graceful",
     "supervisorctl": "pm",
@@ -67,22 +62,30 @@ def get_command(self, ctx, name):
 # Shortcut for running Galaxy in the foreground
 @click.command(context_settings=CONTEXT_SETTINGS)
 @options.debug_option()
+@options.config_file_option()
 @options.state_dir_option()
 @options.no_log_option()
 @click.pass_context
-def galaxy(ctx, debug, state_dir, quiet):
+def galaxy(ctx, debug, config_file, state_dir, quiet):
     """Run Galaxy server in the foreground"""
     set_debug(debug)
-    ctx.state_dir = state_dir
+    ctx.cm_kwargs = {
+        "config_file": config_file,
+        "state_dir": state_dir,
+    }
     mod = __import__("gravity.commands.cmd_start", None, None, ["cli"])
     return ctx.invoke(mod.cli, foreground=True, quiet=quiet)
 
 
 @click.command(cls=GravityCLI, context_settings=CONTEXT_SETTINGS)
 @options.debug_option()
+@options.config_file_option()
 @options.state_dir_option()
 @click.pass_context
-def galaxyctl(ctx, debug, state_dir):
+def galaxyctl(ctx, debug, config_file, state_dir):
     """Manage Galaxy server configurations and processes."""
     set_debug(debug)
-    ctx.state_dir = state_dir
+    ctx.cm_kwargs = {
+        "config_file": config_file,
+        "state_dir": state_dir,
+    }
diff --git a/gravity/commands/cmd_configs.py b/gravity/commands/cmd_configs.py
deleted file mode 100644
index 01ad909..0000000
--- a/gravity/commands/cmd_configs.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import click
-
-from gravity import config_manager
-
-
-@click.command("configs")
-@click.pass_context
-def cli(ctx):
-    """List registered config files.
-
-    aliases: list
-    """
-    with config_manager.config_manager(state_dir=ctx.parent.state_dir) as cm:
-        registered = cm.get_registered_configs()
-        if registered:
-            click.echo("%-12s  %-24s  %s" % ("TYPE", "INSTANCE NAME", "CONFIG PATH"))
-            for config in registered:
-                click.echo("%-12s  %-24s  %s" % (config.get("config_type", "unknown"), config.get("instance_name", "unknown"), config.__file__))
-        else:
-            click.echo("No config files registered")
diff --git a/gravity/commands/cmd_deregister.py b/gravity/commands/cmd_deregister.py
deleted file mode 100644
index 570ef9f..0000000
--- a/gravity/commands/cmd_deregister.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import click
-
-from gravity import config_manager
-from gravity import options
-
-
-@click.command("deregister")
-@options.required_config_arg(nargs=-1)
-@click.pass_context
-def cli(ctx, config):
-    """Deregister config file(s).
-
-    aliases: remove, forget
-    """
-    with config_manager.config_manager(state_dir=ctx.parent.state_dir) as cm:
-        cm.remove(config)
diff --git a/gravity/commands/cmd_exec.py b/gravity/commands/cmd_exec.py
index 54c9c27..c968578 100644
--- a/gravity/commands/cmd_exec.py
+++ b/gravity/commands/cmd_exec.py
@@ -5,9 +5,10 @@
 
 
 @click.command("exec")
+@click.option("--no-exec", "-n", is_flag=True, default=False, help="Don't exec, just print the command that would be run")
 @options.instances_services_arg()
 @click.pass_context
-def cli(ctx, instances_services):
+def cli(ctx, instances_services, no_exec):
     """Run a single Galaxy service in the foreground, with logging output to stdout/stderr.
 
     Zero or one instance names can be provided in INSTANCES, it is required if more than one Galaxy instance is
@@ -15,5 +16,5 @@ def cli(ctx, instances_services):
 
     Exactly one service name is required in SERVICES.
     """
-    with process_manager.process_manager(state_dir=ctx.parent.state_dir, start_daemon=False) as pm:
-        pm.exec(instance_names=instances_services)
+    with process_manager.process_manager(**ctx.parent.cm_kwargs) as pm:
+        pm.exec(instance_names=instances_services, no_exec=no_exec)
diff --git a/gravity/commands/cmd_follow.py b/gravity/commands/cmd_follow.py
index aa09bd4..aa9aac8 100644
--- a/gravity/commands/cmd_follow.py
+++ b/gravity/commands/cmd_follow.py
@@ -5,14 +5,14 @@
 
 
 @click.command("follow")
-@options.required_instance_arg()
+@options.instances_services_arg()
 @click.pass_context
-def cli(ctx, instance):
+def cli(ctx, instances_services):
     """Follow log files of configured instances.
 
-    If INSTANCE matches an instance name, logs of all services configured for the instance are followed.
+    If no INSTANCES or SERVICES are provided, logs of all configured services of all configured instances are followed.
 
-    If INSTANCE does not match an instance name, it is assumed to be a service and only logs of the listed service(s)
-    are followed."""
-    with process_manager.process_manager(state_dir=ctx.parent.state_dir, start_daemon=False) as pm:
-        pm.follow(instance_names=instance)
+    Specifying INSTANCES and SERVICES limits the operation to only the provided instance name(s) and/or service(s).
+    """
+    with process_manager.process_manager(**ctx.parent.cm_kwargs) as pm:
+        pm.follow(instance_names=instances_services)
diff --git a/gravity/commands/cmd_graceful.py b/gravity/commands/cmd_graceful.py
index e7224fa..443bf12 100644
--- a/gravity/commands/cmd_graceful.py
+++ b/gravity/commands/cmd_graceful.py
@@ -5,14 +5,15 @@
 
 
 @click.command("graceful")
-@options.required_instance_arg()
+@options.instances_services_arg()
 @click.pass_context
-def cli(ctx, instance):
+def cli(ctx, instances_services):
     """Gracefully reload configured services.
 
-    If INSTANCE matches an instance name, all services configured for the instance are restarted.
+    If no INSTANCES or SERVICES are provided, all configured services of all configured instances are gracefully
+    reloaded.
 
-    If INSTANCE does not match an instance name, it is assumed to be a service and only the listed service(s) are
-    restarted."""
-    with process_manager.process_manager(state_dir=ctx.parent.state_dir) as pm:
-        pm.graceful(instance_names=instance)
+    Specifying INSTANCES and SERVICES limits the operation to only the provided instance name(s) and/or service(s).
+    """
+    with process_manager.process_manager(**ctx.parent.cm_kwargs) as pm:
+        pm.graceful(instance_names=instances_services)
diff --git a/gravity/commands/cmd_instances.py b/gravity/commands/cmd_instances.py
deleted file mode 100644
index f2a559b..0000000
--- a/gravity/commands/cmd_instances.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import click
-
-from gravity import config_manager
-
-
-@click.command("instances")
-@click.pass_context
-def cli(ctx):
-    """List all known instances."""
-    with config_manager.config_manager(state_dir=ctx.parent.state_dir) as cm:
-        configs = cm.get_registered_configs()
-        instances = cm.get_registered_instance_names()
-        if instances:
-            click.echo("%-24s  %-10s  %-10s  %s" % ("INSTANCE NAME", "TYPE", "SERVER", "NAME"))
-            # not the most efficient...
-            for instance in instances:
-                instance_str = instance
-                for config in configs:
-                    if config["instance_name"] == instance:
-                        for service in config["services"]:
-                            click.echo("%-24s  %-10s  %-10s  %s" % (instance_str, service.config_type, service.service_type, service.service_name))
-                            instance_str = ""
-                if instance_str == instance:
-                    click.echo("%-24s  no services configured" % instance)
diff --git a/gravity/commands/cmd_list.py b/gravity/commands/cmd_list.py
new file mode 100644
index 0000000..32d8b51
--- /dev/null
+++ b/gravity/commands/cmd_list.py
@@ -0,0 +1,34 @@
+import click
+
+from gravity import config_manager
+
+
+@click.command("list")
+@click.option("--version", "-v", is_flag=True, default=False, help="Include Galaxy version in output")
+@click.pass_context
+def cli(ctx, version):
+    """List configured instances.
+
+    aliases: configs
+    """
+    cols = ["{:<8}", "{:<18}", "{}"]
+    head = ["TYPE", "INSTANCE NAME", "CONFIG PATH"]
+    if version:
+        cols.insert(2, "{:<12}")
+        head.insert(2, "VERSION")
+    cols_str = "  ".join(cols)
+    with config_manager.config_manager(**ctx.parent.cm_kwargs) as cm:
+        configs = cm.get_configs()
+        if configs:
+            click.echo(cols_str.format(*head))
+            for config in configs:
+                row = [
+                    config.config_type,
+                    config.instance_name,
+                    config.gravity_config_file,
+                ]
+                if version:
+                    row.insert(2, config.galaxy_version)
+                click.echo(cols_str.format(*row))
+        else:
+            click.echo("No configured instances")
diff --git a/gravity/commands/cmd_pm.py b/gravity/commands/cmd_pm.py
index a80f294..72cb854 100644
--- a/gravity/commands/cmd_pm.py
+++ b/gravity/commands/cmd_pm.py
@@ -3,17 +3,13 @@
 from gravity import process_manager
 
 
-NO_START_COMMANDS = (
-    "shutdown",
-    "status",
-)
-
-
 @click.command("pm")
-@click.argument("pm_arg", nargs=-1)
+@click.argument("pm_args", nargs=-1)
 @click.pass_context
-def cli(ctx, pm_arg):
-    """Invoke process manager (supervisorctl, systemctl) directly."""
-    start_daemon = bool(pm_arg and pm_arg[0] not in NO_START_COMMANDS)
-    with process_manager.process_manager(state_dir=ctx.parent.state_dir, start_daemon=start_daemon) as pm:
-        pm.pm(*pm_arg)
+def cli(ctx, pm_args):
+    """Invoke process manager (supervisorctl, systemctl) directly.
+
+    Any args in PM_ARGS are passed to the process manager command.
+    """
+    with process_manager.process_manager(**ctx.parent.cm_kwargs) as pm:
+        pm.pm(*pm_args)
diff --git a/gravity/commands/cmd_register.py b/gravity/commands/cmd_register.py
deleted file mode 100644
index 6d7d43d..0000000
--- a/gravity/commands/cmd_register.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import click
-
-from gravity import config_manager
-from gravity import options
-
-
-@click.command("register")
-@options.required_config_arg(exists=True, nargs=-1)
-@click.pass_context
-def cli(ctx, config):
-    """Register config file(s).
-
-    aliases: add
-    """
-    with config_manager.config_manager(state_dir=ctx.parent.state_dir) as cm:
-        cm.add(config)
diff --git a/gravity/commands/cmd_rename.py b/gravity/commands/cmd_rename.py
deleted file mode 100644
index 7dfe356..0000000
--- a/gravity/commands/cmd_rename.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import click
-
-from gravity import config_manager
-from gravity import options
-
-
-@click.command("reregister")
-@options.required_config_arg(name="old_config")
-@options.required_config_arg(name="new_config", exists=True)
-@click.pass_context
-def cli(ctx, old_config, new_config):
-    """Update path of registered config file.
-
-    aliases: rename
-    """
-    with config_manager.config_manager(state_dir=ctx.parent.state_dir) as cm:
-        cm.rename(old_config, new_config)
diff --git a/gravity/commands/cmd_restart.py b/gravity/commands/cmd_restart.py
index 4bface4..8fab244 100644
--- a/gravity/commands/cmd_restart.py
+++ b/gravity/commands/cmd_restart.py
@@ -5,14 +5,14 @@
 
 
 @click.command("restart")
-@options.required_instance_arg()
+@options.instances_services_arg()
 @click.pass_context
-def cli(ctx, instance):
+def cli(ctx, instances_services):
     """Restart configured services.
 
-    If INSTANCE matches an instance name, all services configured for the instance are restarted.
+    If no INSTANCES or SERVICES are provided, all configured services of all configured instances are restarted.
 
-    If INSTANCE does not match an instance name, it is assumed to be a service and only the listed service(s) are
-    restarted."""
-    with process_manager.process_manager(state_dir=ctx.parent.state_dir) as pm:
-        pm.restart(instance_names=instance)
+    Specifying INSTANCES and SERVICES limits the operation to only the provided instance name(s) and/or service(s).
+    """
+    with process_manager.process_manager(**ctx.parent.cm_kwargs) as pm:
+        pm.restart(instance_names=instances_services)
diff --git a/gravity/commands/cmd_show.py b/gravity/commands/cmd_show.py
index bde1a98..53d7e9f 100644
--- a/gravity/commands/cmd_show.py
+++ b/gravity/commands/cmd_show.py
@@ -1,32 +1,20 @@
-import sys
+import json
 
 import click
 
 from gravity import config_manager
-from gravity import options
-from gravity.io import exception
 
 
 @click.command("show")
-@options.required_config_arg(exists=True)
+@click.argument("instance", required=False)
 @click.pass_context
-def cli(ctx, config):
-    """Show details of registered config.
+def cli(ctx, instance):
+    """Show details of instance config.
+
+    INSTANCE is optional unless there is more than one Galaxy instance configured.
 
     aliases: get
     """
-    with config_manager.config_manager(state_dir=ctx.parent.state_dir) as cm:
-        config_data = cm.get_registered_config(config)
-        if config_data is None:
-            newline = "\n"
-            error_message = f"{config} is not a registered config file.{newline}"
-            registered_configs = cm.get_registered_configs()
-            if registered_configs:
-                registered_configs_str = "\n".join([c.__file__ for c in registered_configs])
-                error_message = f'{error_message}Registered config files are:{newline}{registered_configs_str}{newline}{newline}'
-            else:
-                error_message = f'{error_message}No config files have been registered.{newline}{newline}'
-            error_message = f'{error_message}To register this config file run "galaxyctl register {config}".'
-            exception(error_message)
-        else:
-            config_data.dump(sys.stdout)
+    with config_manager.config_manager(**ctx.parent.cm_kwargs) as cm:
+        config_data = cm.get_config(instance_name=instance)
+        click.echo(json.dumps(config_data.dict(), indent=4))
diff --git a/gravity/commands/cmd_shutdown.py b/gravity/commands/cmd_shutdown.py
index 241928f..3805f59 100644
--- a/gravity/commands/cmd_shutdown.py
+++ b/gravity/commands/cmd_shutdown.py
@@ -7,5 +7,5 @@
 @click.pass_context
 def cli(ctx):
     """Shut down process manager."""
-    with process_manager.process_manager(state_dir=ctx.parent.state_dir, start_daemon=False) as pm:
+    with process_manager.process_manager(**ctx.parent.cm_kwargs) as pm:
         pm.shutdown()
diff --git a/gravity/commands/cmd_start.py b/gravity/commands/cmd_start.py
index 1059d3e..ad437c4 100644
--- a/gravity/commands/cmd_start.py
+++ b/gravity/commands/cmd_start.py
@@ -6,34 +6,38 @@
 
 
 @click.command("start")
-@options.required_instance_arg()
+@options.instances_services_arg()
 @click.option("-f", "--foreground", is_flag=True, default=False, help="Run in foreground")
 @options.no_log_option()
 @click.pass_context
-def cli(ctx, foreground, instance, quiet=False):
+def cli(ctx, instances_services, foreground, quiet=False):
     """Start configured services.
 
-    If INSTANCE matches an instance name, all services configured for the instance are started.
+    If no INSTANCES or SERVICES are provided, all configured services of all configured instances are started.
 
-    If INSTANCE does not match an instance name, it is assumed to be a service and only the listed service(s) are
-    started."""
-    if not instance:
-        with config_manager.config_manager(state_dir=ctx.parent.state_dir) as cm:
-            # If there are no configs registered, we will attempt to auto-register one
-            cm.auto_register()
+    Specifying INSTANCES and SERVICES limits the operation to only the provided instance name(s) and/or service(s).
+    """
+    auto_update = False
+    if not instances_services and not ctx.parent.cm_kwargs["config_file"]:
+        with config_manager.config_manager(**ctx.parent.cm_kwargs) as cm:
+            # If there are no configs known, we will attempt to auto-load one
+            cm.auto_load()
+            auto_update = True
         if not cm.instance_count:
             exception(
                 "Nothing to start: no Galaxy instances configured and no Galaxy configuration files found, "
-                "see `galaxyctl register --help`")
-    with process_manager.process_manager(state_dir=ctx.parent.state_dir, foreground=foreground) as pm:
-        pm.start(instance_names=instance)
+                "see `galaxyctl --help`")
+    with process_manager.process_manager(foreground=foreground, **ctx.parent.cm_kwargs) as pm:
+        if auto_update:
+            pm.update()
+        pm.start(instance_names=instances_services)
         if foreground:
-            pm.follow(instance_names=instance, quiet=quiet)
+            pm.follow(instance_names=instances_services, quiet=quiet)
         elif pm.config_manager.single_instance:
-            config = list(pm.config_manager.get_registered_configs())[0]
+            config = pm.config_manager.get_config()
             if config.process_manager != "systemd":
-                info(f"Log files are in {config.attribs['log_dir']}")
+                info(f"Log files are in {config.log_dir}")
         else:
-            for config in pm.config_manager.get_registered_configs(instances=instance or None):
+            for config in pm.config_manager.get_configs(instances=instances_services or None):
                 if config.process_manager != "systemd":
-                    info(f"Log files for {config.instance_name} are in {config.attribs['log_dir']}")
+                    info(f"Log files for {config.instance_name} are in {config.log_dir}")
diff --git a/gravity/commands/cmd_status.py b/gravity/commands/cmd_status.py
index c1973e0..9deeab1 100644
--- a/gravity/commands/cmd_status.py
+++ b/gravity/commands/cmd_status.py
@@ -7,5 +7,5 @@
 @click.pass_context
 def cli(ctx):
     """Display server status."""
-    with process_manager.process_manager(state_dir=ctx.parent.state_dir, start_daemon=False) as pm:
+    with process_manager.process_manager(**ctx.parent.cm_kwargs) as pm:
         pm.status()
diff --git a/gravity/commands/cmd_stop.py b/gravity/commands/cmd_stop.py
index 858c4e4..883de29 100644
--- a/gravity/commands/cmd_stop.py
+++ b/gravity/commands/cmd_stop.py
@@ -5,14 +5,14 @@
 
 
 @click.command("stop")
-@options.required_instance_arg()
+@options.instances_services_arg()
 @click.pass_context
-def cli(ctx, instance):
+def cli(ctx, instances_services):
     """Stop configured services.
 
-    If INSTANCE matches an instance name, all services configured for the instance are stopped.
+    If no INSTANCES or SERVICES are provided, all configured services of all configured instances are stopped.
 
-    If INSTANCE does not match an instance name, it is assumed to be a service and only the listed service(s) are
-    stopped."""
-    with process_manager.process_manager(state_dir=ctx.parent.state_dir, start_daemon=False) as pm:
-        pm.stop(instance_names=instance)
+    Specifying INSTANCES and SERVICES limits the operation to only the provided instance name(s) and/or service(s).
+    """
+    with process_manager.process_manager(**ctx.parent.cm_kwargs) as pm:
+        pm.stop(instance_names=instances_services)
diff --git a/gravity/commands/cmd_update.py b/gravity/commands/cmd_update.py
index f48c25c..5e08606 100644
--- a/gravity/commands/cmd_update.py
+++ b/gravity/commands/cmd_update.py
@@ -5,8 +5,9 @@
 
 @click.command("update")
 @click.option("--force", is_flag=True, help="Force rewriting of process config files")
+@click.option("--clean", is_flag=True, help="Remove process config files if they exist")
 @click.pass_context
-def cli(ctx, force):
+def cli(ctx, force, clean):
     """Update process manager from config changes."""
-    with process_manager.process_manager(state_dir=ctx.parent.state_dir, start_daemon=False) as pm:
-        pm.update(force=force)
+    with process_manager.process_manager(**ctx.parent.cm_kwargs) as pm:
+        pm.update(force=force, clean=clean)
diff --git a/gravity/config_manager.py b/gravity/config_manager.py
index b628e27..a1c51f6 100644
--- a/gravity/config_manager.py
+++ b/gravity/config_manager.py
@@ -1,192 +1,207 @@
 """ Galaxy Process Management superclass and utilities
 """
 import contextlib
-import errno
-import hashlib
+import glob
 import logging
 import os
-import shutil
 import xml.etree.ElementTree as elementtree
-from os import pardir
-from os.path import abspath, dirname, exists, expanduser, isabs, join
 from typing import Union
 
-import packaging.version
 from yaml import safe_load
 
 from gravity.settings import Settings
-from gravity.io import debug, error, exception, info, warn
+from gravity.io import debug, error, exception, warn
 from gravity.state import (
     ConfigFile,
-    GravityState,
     service_for_service_type,
 )
-from gravity.util import recursive_update, yaml_safe_load_with_include
+from gravity.util import recursive_update
 
 log = logging.getLogger(__name__)
 
 DEFAULT_JOB_CONFIG_FILE = "config/job_conf.xml"
-DEFAULT_STATE_DIR = join("~", ".config", "galaxy-gravity")
-DEFAULT_ROOT_STATE_DIR = "/var/lib/gravity"
 if "XDG_CONFIG_HOME" in os.environ:
-    DEFAULT_STATE_DIR = join(os.environ["XDG_CONFIG_HOME"], "galaxy-gravity")
+    DEFAULT_STATE_DIR = os.path.join(os.environ["XDG_CONFIG_HOME"], "galaxy-gravity")
 
 
 @contextlib.contextmanager
-def config_manager(state_dir=None, python_exe=None):
-    yield ConfigManager(state_dir=state_dir, python_exe=python_exe)
+def config_manager(config_file=None, state_dir=None):
+    yield ConfigManager(config_file=config_file, state_dir=state_dir)
 
 
 class ConfigManager(object):
     galaxy_server_config_section = "galaxy"
     gravity_config_section = "gravity"
+    app_config_file_option = "galaxy_config_file"
 
-    def __init__(self, state_dir=None, python_exe=None):
-        if state_dir is None:
-            if self.is_root:
-                state_dir = DEFAULT_ROOT_STATE_DIR
-            else:
-                state_dir = DEFAULT_STATE_DIR
-        self.state_dir = abspath(expanduser(state_dir))
-        debug(f"Gravity state dir: {self.state_dir}")
+    def __init__(self, config_file=None, state_dir=None):
         self.__configs = {}
-        self.config_state_path = join(self.state_dir, "configstate.yaml")
-        self.python_exe = python_exe
-        try:
-            os.makedirs(self.state_dir)
-        except OSError as exc:
-            if exc.errno != errno.EEXIST:
-                raise
-        self.__convert_config()
-
-    def __copy_config(self, old_path):
-        with GravityState.open(old_path) as state:
-            state.set_name(self.config_state_path)
-        # copies on __exit__
-
-    def __backup_config(self, backup_ext):
-        backup_path = f"{self.config_state_path}.{backup_ext}"
-        info(f"Previous Gravity config state saved in: {backup_path}")
-        shutil.copy(self.config_state_path, backup_path)
-
-    def __convert_config(self):
-        # the gravity version has been included in the configstate since 0.10.0, but it was previously a configfile
-        # attrib, which doesn't really make sense, and 1.0.0 removes persisted configfile attribs anyway
-        state_version = self.state.get("gravity_version")
-        debug(f"Gravity state version: {state_version}")
-        if not state_version or (packaging.version.parse(state_version) < packaging.version.parse("1.0.0")):
-            # this hardcoded versioning suffices for now, might have to get fancier in the future
-            with self.state as state:
-                self.__convert_config_1_0(state)
-
-    def __convert_config_1_0(self, state):
-        info("Converting Gravity config state to 1.0 format, this will only occur once")
-        self.__backup_config("pre-1.0")
-        for config_file, config in state.config_files.items():
-            try:
-                config.galaxy_root = config.attribs["galaxy_root"]
-            except KeyError:
-                warn(f"Unable to read 'galaxy_root' from attribs: {config.attribs}")
-            for key in list(config.keys()):
-                if key not in ConfigFile.persist_keys:
-                    del config[key]
-            state.config_files[config_file] = config
+        self.state_dir = None
+        if state_dir is not None:
+            # convert from pathlib.Path
+            self.state_dir = str(state_dir)
+
+        debug(f"Gravity state dir: {state_dir}")
+
+        if config_file:
+            for cf in config_file:
+                self.load_config_file(cf)
+        else:
+            self.auto_load()
 
     @property
     def is_root(self):
         return os.geteuid() == 0
 
-    def get_config(self, conf, defaults=None):
-        if conf in self.__configs:
-            return self.__configs[conf]
+    def load_config_file(self, config_file):
+        with open(config_file) as config_fh:
+            try:
+                config_dict = safe_load(config_fh)
+            except Exception as exc:
+                # this should always be a parse error, access errors will be caught by click
+                error(f"Failed to parse config: {config_file}")
+                exception(exc)
+
+        if type(config_dict) is not dict:
+            exception(f"Config file does not look like valid Galaxy or Gravity configuration file: {config_file}")
+
+        gravity_config_dict = config_dict.get(self.gravity_config_section) or {}
 
-        defaults = defaults or {}
+        if type(gravity_config_dict) is list:
+            self.__load_config_list(config_file, config_dict)
+            return
+
+        app_config = None
         server_section = self.galaxy_server_config_section
-        with open(conf) as config_fh:
-            config_dict = yaml_safe_load_with_include(config_fh)
-        _gravity_config = config_dict.get(self.gravity_config_section) or {}
-        gravity_config = Settings(**recursive_update(defaults, _gravity_config))
-        if gravity_config.log_dir is None:
-            gravity_config.log_dir = join(expanduser(self.state_dir), "log")
-
-        if server_section not in config_dict and self.gravity_config_section not in config_dict:
-            error(f"Config file {conf} does not look like valid Galaxy, Reports or Tool Shed configuration file")
-            return None
-
-        app_config = config_dict.get(server_section) or {}
-
-        config = ConfigFile()
-        config.attribs = {}
-        config.services = []
-        config.__file__ = conf
-        # FIXME: I don't think we use the persisted value of instance_name anymore, this comes straight from the Gravity
-        # config. We might not need it at all, but we also need to validate that it doesn't collide with other
-        # registered instances on every load, in case the admin has changed the instance name since last run.
-        config.instance_name = gravity_config.instance_name
-        config.config_type = server_section
-        config.process_manager = gravity_config.process_manager
-        config.service_command_style = gravity_config.service_command_style
-        # FIXME: should this be attribs?
-        config.attribs["galaxy_infrastructure_url"] = app_config.get("galaxy_infrastructure_url", "").rstrip("/")
-        if gravity_config.tusd.enable and not config.attribs["galaxy_infrastructure_url"]:
-            exception("To run the tusd server you need to set galaxy_infrastructure_url in the galaxy section of galaxy.yml")
-        config.attribs["app_server"] = gravity_config.app_server
-        config.attribs["log_dir"] = gravity_config.log_dir
-        config.attribs["virtualenv"] = gravity_config.virtualenv
-        config.attribs["gunicorn"] = gravity_config.gunicorn.dict()
-        config.attribs["tusd"] = gravity_config.tusd.dict()
-        config.attribs["celery"] = gravity_config.celery.dict()
-        config.attribs["handlers"] = gravity_config.handlers
-        config.attribs["galaxy_user"] = gravity_config.galaxy_user
-        config.attribs["galaxy_group"] = gravity_config.galaxy_group
-        config.attribs["memory_limit"] = gravity_config.memory_limit
-        # Store gravity version, in case we need to convert old setting
-        webapp_service_names = []
-
-        # shortcut for galaxy configs in the standard locations -- explicit arg ?
-        config["galaxy_root"] = app_config.get("root") or gravity_config.galaxy_root
-        if config["galaxy_root"] is None:
-            if os.environ.get("GALAXY_ROOT_DIR"):
-                config["galaxy_root"] = abspath(os.environ["GALAXY_ROOT_DIR"])
-            elif exists(join(dirname(conf), pardir, "lib", "galaxy")):
-                config["galaxy_root"] = abspath(join(dirname(conf), pardir))
-            elif conf.endswith(join('galaxy', 'config', 'sample', 'galaxy.yml.sample')):
-                config["galaxy_root"] = abspath(join(dirname(conf), pardir, pardir, pardir, pardir))
+        if self.gravity_config_section in config_dict and server_section not in config_dict:
+            app_config_file = config_dict[self.gravity_config_section].get(self.app_config_file_option)
+            if app_config_file:
+                app_config = self.__load_app_config_file(config_file, app_config_file)
             else:
-                exception(f"Cannot locate Galaxy root directory: set $GALAXY_ROOT_DIR or `root' in the `galaxy' section of {conf}")
+                warn(
+                    f"Config file appears to be a Gravity config but contains no {server_section} section, "
+                    f"Galaxy defaults will be used: {config_file}")
+        elif self.gravity_config_section not in config_dict and server_section in config_dict:
+            warn(
+                f"Config file appears to be a Galaxy config but contains no {self.gravity_config_section} section, "
+                f"Gravity defaults will be used: {config_file}")
+        elif self.gravity_config_section not in config_dict and server_section not in config_dict:
+            exception(f"Config file does not look like valid Galaxy or Gravity configuration file: {config_file}")
+
+        app_config = app_config or config_dict.get(server_section) or {}
+        gravity_config_dict["__file__"] = config_file
+        self.__load_config(gravity_config_dict, app_config)
+
+    def __load_app_config_file(self, gravity_config_file, app_config_file):
+        server_section = self.galaxy_server_config_section
+        if not os.path.isabs(app_config_file):
+            app_config_file = os.path.join(os.path.dirname(gravity_config_file), app_config_file)
+        try:
+            with open(app_config_file) as config_fh:
+                _app_config_dict = safe_load(config_fh)
+                if server_section not in _app_config_dict:
+                    # we let a missing galaxy config slide in other scenarios but if you set the option to something
+                    # that doesn't contain a galaxy section that's almost surely a mistake
+                    exception(f"Galaxy config file does not contain a {server_section} section: {app_config_file}")
+            app_config = _app_config_dict[server_section] or {}
+            app_config["__file__"] = app_config_file
+            return app_config
+        except Exception as exc:
+            exception(exc)
+
+    def __load_config_list(self, config_file, config_dict):
+        try:
+            assert self.galaxy_server_config_section not in config_dict, (
+                "Multiple Gravity configurations in a shared Galaxy configuration file is ambiguous, set "
+                f"`{self.app_config_file_option}` and remove the Galaxy configuration: {config_file}"
+            )
+            for gravity_config_dict in config_dict[self.gravity_config_section]:
+                assert "galaxy_config_file" in gravity_config_dict, (
+                    "The `{self.app_config_file_option}` option must be set when multiple Gravity configurations are "
+                    f"present: {config_file}"
+                )
+                app_config = self.__load_app_config_file(config_file, gravity_config_dict[self.app_config_file_option])
+                gravity_config_dict["__file__"] = config_file
+                self.__load_config(gravity_config_dict, app_config)
+        except AssertionError as exc:
+            exception(exc)
+
+    def __load_config(self, gravity_config_dict, app_config):
+        defaults = {}
+        gravity_config = Settings(**recursive_update(defaults, gravity_config_dict))
+
+        if gravity_config.instance_name in self.__configs:
+            error(
+                f"Galaxy instance {gravity_config.instance_name} already loaded from file: "
+                f"{self.__configs[gravity_config.instance_name].gravity_config_file}")
+            exception(f"Duplicate instance name {gravity_config.instance_name}, instance names must be unique")
+
+        gravity_config_file = gravity_config_dict["__file__"]
+        galaxy_config_file = app_config.get("__file__", gravity_config_file)
+
+        service_settings = {}
+        service_settings["gunicorn"] = gravity_config.gunicorn.dict()
+        service_settings["tusd"] = gravity_config.tusd.dict()
+        service_settings["celery"] = gravity_config.celery.dict()
+        service_settings["reports"] = gravity_config.reports.dict()
+
+        galaxy_root = gravity_config.galaxy_root or app_config.get("root")
+
+        # TODO: document that the default state_dir is data_dir/gravity and that setting state_dir overrides this
+        gravity_data_dir = self.state_dir or os.path.join(app_config.get("data_dir", "database"), "gravity")
+        log_dir = gravity_config.log_dir or os.path.join(gravity_data_dir, "log")
+
+        config = ConfigFile(
+            config_type=self.galaxy_server_config_section,
+            gravity_config_file=gravity_config_file,
+            galaxy_config_file=galaxy_config_file,
+            instance_name=gravity_config.instance_name,
+            process_manager=gravity_config.process_manager,
+            service_command_style=gravity_config.service_command_style,
+            app_server=gravity_config.app_server,
+            virtualenv=gravity_config.virtualenv,
+            galaxy_infrastructure_url=app_config.get("galaxy_infrastructure_url", "").rstrip("/"),
+            galaxy_root=galaxy_root,
+            galaxy_user=gravity_config.galaxy_user,
+            galaxy_group=gravity_config.galaxy_group,
+            umask=gravity_config.umask,
+            memory_limit=gravity_config.memory_limit,
+            gravity_data_dir=gravity_data_dir,
+            log_dir=log_dir,
+        )
+
+        service_kwargs = {
+            "config": config,
+            "service_settings": service_settings,
+        }
 
         if gravity_config.gunicorn.enable:
-            if config.attribs["gunicorn"]["preload"] is None:
-                config.attribs["gunicorn"]["preload"] = config.attribs["app_server"] != "unicornherder"
-            config.services.append(service_for_service_type(config.attribs["app_server"])(config_type=config.config_type))
+            config.services.append(service_for_service_type(config.app_server)(**service_kwargs))
         if gravity_config.celery.enable:
-            config.services.append(service_for_service_type("celery")(config_type=config.config_type))
+            config.services.append(service_for_service_type("celery")(**service_kwargs))
         if gravity_config.celery.enable_beat:
-            config.services.append(service_for_service_type("celery-beat")(config_type=config.config_type))
+            config.services.append(service_for_service_type("celery-beat")(**service_kwargs))
         if gravity_config.tusd.enable:
-            config.services.append(service_for_service_type("tusd")(config_type=config.config_type))
+            config.services.append(service_for_service_type("tusd")(**service_kwargs))
+        if gravity_config.reports.enable:
+            config.services.append(service_for_service_type("reports")(**service_kwargs))
 
         if not app_config.get("job_config_file") and app_config.get("job_config"):
             # config embedded directly in Galaxy config
             job_config = app_config["job_config"]
         else:
             # If this is a Galaxy config, parse job_conf.xml for any *static* standalone handlers
-            # TODO: use galaxy config parsing ?
             job_config = app_config.get("job_config_file", DEFAULT_JOB_CONFIG_FILE)
-            if not isabs(job_config):
-                # FIXME: relative to root
-                job_config = abspath(join(config["galaxy_root"], job_config))
-                if not exists(job_config):
+            if not os.path.isabs(job_config):
+                job_config = os.path.abspath(os.path.join(os.path.dirname(config.galaxy_config_file), job_config))
+                if not os.path.exists(job_config):
                     job_config = None
         if config.config_type == "galaxy" and job_config:
-            for handler_settings in [x for x in ConfigManager.get_job_config(job_config) if x["service_name"] not in webapp_service_names]:
+            for handler_settings in ConfigManager.get_job_config(job_config):
                 config.services.append(service_for_service_type("standalone")(
-                    config_type=config.config_type,
-                    service_name=handler_settings["service_name"],
-                    environment=handler_settings.get("environment"),
-                    memory_limit=handler_settings.get("memory_limit"),
-                    start_timeout=handler_settings.get("start_timeout"),
-                    stop_timeout=handler_settings.get("stop_timeout")
+                    config=config,
+                    service_name=handler_settings.pop("service_name"),
+                    service_settings={"standalone": handler_settings},
                 ))
 
         # FIXME: This should imply explicit configuration of the handler assignment method. If not explicitly set, the
@@ -195,7 +210,8 @@ def get_config(self, conf, defaults=None):
         # see how this is determined.
         self.create_handler_services(gravity_config, config)
         self.create_gxit_services(gravity_config, app_config, config)
-        self.__configs[conf] = config
+        self.__configs[config.instance_name] = config
+        debug(f"Loaded instance {config.instance_name} from Gravity config file: {config.gravity_config_file}")
         return config
 
     def create_handler_services(self, gravity_config: Settings, config):
@@ -204,21 +220,13 @@ def create_handler_services(self, gravity_config: Settings, config):
         # handlers, and gravity is only 1 of them.
         expanded_handlers = self.expand_handlers(gravity_config, config)
         for service_name, handler_settings in expanded_handlers.items():
-            pools = handler_settings.get('pools')
-            environment = handler_settings.get("environment")
-            # TODO: add these to Galaxy docs
-            start_timeout = handler_settings.get("start_timeout")
-            stop_timeout = handler_settings.get("stop_timeout")
-            memory_limit = handler_settings.get("memory_limit")
+            if "pools" in handler_settings:
+                handler_settings["server_pools"] = handler_settings.pop("pools")
             config.services.append(
                 service_for_service_type("standalone")(
-                    config_type=config.config_type,
+                    config=config,
                     service_name=service_name,
-                    server_pools=pools,
-                    environment=environment,
-                    start_timeout=start_timeout,
-                    stop_timeout=stop_timeout,
-                    memory_limit=memory_limit
+                    service_settings={"standalone": handler_settings},
                 ))
 
     def create_gxit_services(self, gravity_config: Settings, app_config, config):
@@ -230,9 +238,9 @@ def create_gxit_services(self, gravity_config: Settings, app_config, config):
             # CWD in supervisor template is galaxy_root, so this should work for simple cases as is
             gxit_config = gravity_config.gx_it_proxy
             gxit_config.sessions = app_config.get("interactivetools_map", gxit_config.sessions)
-            gxit_config.verbose = '--verbose' if gxit_config.verbose else ''
-            config.services.append(service_for_service_type("gx-it-proxy")(config_type=config.config_type))
-        config.attribs["gx-it-proxy"] = gravity_config.gx_it_proxy.dict()
+            # technically the tusd service doesn't have access to the rest of the settings like other services do, but it doesn't need it
+            service_kwargs = dict(config=config, service_settings={"gx-it-proxy": gravity_config.gx_it_proxy.dict()})
+            config.services.append(service_for_service_type("gx-it-proxy")(**service_kwargs))
 
     @staticmethod
     def expand_handlers(gravity_config: Settings, config):
@@ -280,133 +288,73 @@ def get_job_config(conf: Union[str, dict]):
                 })
         return rval
 
-    def _register_config_file(self, key, val):
-        """Persist a newly added config file, or update (overwrite) the value
-        of a previously persisted config.
-        """
-        with self.state as state:
-            state.config_files[key] = val
-
-    def _deregister_config_file(self, key):
-        """Deregister a previously registered config file.  The caller should
-        ensure that it was previously registered.
-        """
-        with self.state as state:
-            del state.config_files[key]
-
-    @property
-    def state(self):
-        """Public property to access persisted config state"""
-        return GravityState.open(self.config_state_path)
-
     @property
     def instance_count(self):
         """The number of configured instances"""
-        return len(self.state.config_files)
+        return len(self.__configs)
 
     @property
     def single_instance(self):
         """Indicate if there is only one configured instance"""
         return self.instance_count == 1
 
-    def get_registered_configs(self, instances=None, process_manager=None):
+    def is_loaded(self, config_file):
+        return config_file in self.get_configured_files()
+
+    def get_configs(self, instances=None, process_manager=None):
         """Return the persisted values of all config files registered with the config manager."""
         rval = []
-        config_files = self.state.config_files
-        for config_file, config in list(config_files.items()):
-            # if ((instances is not None and config["instance_name"] in instances) or instances is None) and (
-            #     (process_manager is not None and config_pm == process_manager) or process_manager is None
-            # ):
-            # TODO: if we add process_manager to the state, then we can filter for it as above instead of after
-            # get_config as below
-            if (instances is not None and config["instance_name"] in instances) or instances is None:
-                config = self.get_config(config_file)
-                if (process_manager is not None and config["process_manager"] == process_manager) or process_manager is None:
-                    rval.append(config)
+        for instance_name, config in list(self.__configs.items()):
+            if ((instances is not None and instance_name in instances) or instances is None) and (
+                (process_manager is not None and config.process_manager == process_manager) or process_manager is None
+            ):
+                rval.append(config)
         return rval
 
-    def get_registered_config(self, config_file):
-        """Return the persisted value of the named config file."""
-        if config_file in self.state.config_files:
-            return self.get_config(config_file)
-        return None
+    def get_config(self, instance_name=None):
+        if instance_name is None:
+            if self.instance_count > 1:
+                exception("An instance name is required when more than one instance is configured")
+            elif self.instance_count == 0:
+                exception("No configured Galaxy instances")
+            instance_name = list(self.__configs.keys())[0]
+        try:
+            return self.__configs[instance_name]
+        except KeyError:
+            exception(f"Unknown instance name: {instance_name}")
 
     def get_configured_service_names(self):
         rval = set()
-        for config in self.get_registered_configs():
-            for service in config["services"]:
-                rval.add(service["service_name"])
+        for config in self.get_configs():
+            for service in config.services:
+                rval.add(service.service_name)
         return rval
 
-    def get_registered_instance_names(self):
-        return [c["instance_name"] for c in self.state.config_files.values()]
+    def get_configured_instance_names(self):
+        return list(self.__configs.keys())
 
-    def auto_register(self):
-        """Attempt to automatically register a config file if none are registered."""
-        if self.instance_count == 0:
-            if os.environ.get("GALAXY_CONFIG_FILE"):
-                configs = [os.environ["GALAXY_CONFIG_FILE"]]
-            else:
-                configs = (os.path.join("config", "galaxy.yml"), os.path.join("config", "galaxy.yml.sample"))
-            for config in configs:
-                if exists(config):
-                    # This should always be the case if instance_count == 0
-                    if not self.is_registered(abspath(config)):
-                        self.add([config])
-                    return
+    def get_configured_files(self):
+        return list(c.gravity_config_file for c in self.__configs.values())
 
-    def is_registered(self, config_file):
-        return config_file in self.state.config_files
-
-    def add(self, config_files, galaxy_root=None):
-        """Public method to add (register) config file(s)."""
-        for config_file in config_files:
-            config_file = abspath(expanduser(config_file))
-            if self.is_registered(config_file):
-                warn("%s is already registered", config_file)
-                continue
-            defaults = None
-            if galaxy_root is not None:
-                defaults = {"galaxy_root": galaxy_root}
-            conf = self.get_config(config_file, defaults=defaults)
-            if conf is None:
-                exception(f"Cannot add {config_file}: File is unknown type")
-            if conf["instance_name"] is None:
-                conf["instance_name"] = conf["config_type"] + "-" + hashlib.md5(os.urandom(32)).hexdigest()[:12]
-            if conf["instance_name"] in self.get_registered_instance_names():
-                exception(f"Cannot add {config_file}: instance_name '{conf['instance_name']}' already in use")
-            conf_data = {}
-            for key in ConfigFile.persist_keys:
-                conf_data[key] = conf[key]
-            self._register_config_file(config_file, conf_data)
-            info("Registered %s config: %s", conf["config_type"], config_file)
-
-    def rename(self, old, new):
-        if not self.is_registered(old):
-            error("%s is not registered", old)
+    def auto_load(self):
+        """Attempt to automatically load a config file if none are loaded."""
+        load_all = False
+        if self.instance_count != 0:
             return
-        conf = self.get_config(new)
-        if conf is None:
-            exception(f"Cannot add {new}: File is unknown type")
-        with self.state as state:
-            state.config_files[new] = state.config_files.pop(old)
-        info("Reregistered config %s as %s", old, new)
-
-    def remove(self, config_files):
-        # FIXME: paths are checked by click now
-        # allow the arg to be instance names
-        configs_by_instance = self.get_registered_configs(instances=config_files)
-        if configs_by_instance:
-            supplied_config_files = []
-            config_files = list(configs_by_instance.keys())
+        if os.environ.get("GALAXY_CONFIG_FILE"):
+            configs = [os.environ["GALAXY_CONFIG_FILE"]]
+        elif self.is_root:
+            load_all = True
+            configs = (
+                "/etc/galaxy/gravity.yml",
+                "/etc/galaxy/galaxy.yml",
+                *glob.glob("/etc/galaxy/gravity.d/*.yml"),
+                *glob.glob("/etc/galaxy/gravity.d/*.yaml"),
+            )
         else:
-            supplied_config_files = [abspath(cf) for cf in config_files]
-            config_files = []
-        for config_file in supplied_config_files:
-            if not self.is_registered(config_file):
-                warn("%s is not registered", config_file)
-            else:
-                config_files.append(config_file)
-        for config_file in config_files:
-            self._deregister_config_file(config_file)
-            info("Deregistered config: %s", config_file)
+            configs = (os.path.join("config", "galaxy.yml"), os.path.join("config", "galaxy.yml.sample"))
+        for config in configs:
+            if os.path.exists(config):
+                self.load_config_file(os.path.abspath(config))
+                if not load_all:
+                    return
diff --git a/gravity/options.py b/gravity/options.py
index 78908b7..1917672 100644
--- a/gravity/options.py
+++ b/gravity/options.py
@@ -13,6 +13,16 @@ def state_dir_option():
     )
 
 
+def config_file_option():
+    return click.option(
+        "-c",
+        "--config-file",
+        type=click.Path(exists=True, dir_okay=False, resolve_path=True),
+        multiple=True,
+        help="Gravity (or Galaxy) config file to operate on. Can also be set with $GRAVITY_CONFIG_FILE or $GALAXY_CONFIG_FILE",
+    )
+
+
 def no_log_option():
     return click.option(
         '--quiet', is_flag=True, default=False, help="Only output supervisor logs, do not include process logs"
@@ -33,9 +43,5 @@ def required_config_arg(name="config", exists=False, nargs=None):
         return click.argument(name, nargs=nargs, type=arg_type)
 
 
-def required_instance_arg():
-    return click.argument("instance", nargs=-1)
-
-
 def instances_services_arg():
     return click.argument("instances_services", metavar="[INSTANCES] [SERVICES]", nargs=-1)
diff --git a/gravity/process_manager/__init__.py b/gravity/process_manager/__init__.py
index 2dada37..1e87f40 100644
--- a/gravity/process_manager/__init__.py
+++ b/gravity/process_manager/__init__.py
@@ -7,12 +7,13 @@
 import os
 import shlex
 import subprocess
+import sys
 from abc import ABCMeta, abstractmethod
 from functools import partial, wraps
 
 from gravity.config_manager import ConfigManager
 from gravity.io import debug, exception, info, warn
-from gravity.settings import ServiceCommandStyle
+from gravity.settings import DEFAULT_INSTANCE_NAME, ServiceCommandStyle
 from gravity.state import VALID_SERVICE_NAMES
 from gravity.util import which
 
@@ -34,7 +35,7 @@ def decorator(self, *args, instance_names=None, **kwargs):
         configs_by_pm = {}
         pm_names = self.process_managers.keys()
         instance_names, service_names = self._instance_service_names(instance_names)
-        configs = self.config_manager.get_registered_configs(instances=instance_names or None)
+        configs = self.config_manager.get_configs(instances=instance_names or None)
         for config in configs:
             try:
                 configs_by_pm[config.process_manager].append(config)
@@ -63,9 +64,8 @@ def decorator(self, *args, instance_names=None, **kwargs):
 
 
 class BaseProcessExecutionEnvironment(metaclass=ABCMeta):
-    def __init__(self, state_dir=None, config_manager=None, start_daemon=True, foreground=False):
-        self.config_manager = config_manager or ConfigManager(state_dir=state_dir)
-        self.state_dir = self.config_manager.state_dir
+    def __init__(self, state_dir=None, config_file=None, config_manager=None, **kwargs):
+        self.config_manager = config_manager or ConfigManager(state_dir=state_dir, config_file=config_file)
         self.tail = which("tail")
 
     @abstractmethod
@@ -79,61 +79,62 @@ def _service_log_file(self, log_dir, program_name):
         return os.path.join(log_dir, program_name + ".log")
 
     def _service_program_name(self, instance_name, service):
-        return f"{instance_name}_{service['config_type']}_{service['service_type']}_{service['service_name']}"
-
-    def _service_environment(self, service, attribs):
-        environment = service.get_environment()
-        environment_from = service.environment_from
-        if not environment_from:
-            environment_from = service.service_type
-        environment.update(attribs.get(environment_from, {}).get("environment", {}))
-        return environment
+        return f"{instance_name}_{service.config_type}_{service.service_type}_{service.service_name}"
 
     def _service_format_vars(self, config, service, program_name, pm_format_vars):
-        attribs = config.attribs
-        virtualenv_dir = attribs.get("virtualenv")
+        virtualenv_dir = config.virtualenv
         virtualenv_bin = f'{os.path.join(virtualenv_dir, "bin")}{os.path.sep}' if virtualenv_dir else ""
 
         format_vars = {
-            "config_type": service["config_type"],
-            "server_name": service["service_name"],
+            "config_type": service.config_type,
+            "server_name": service.service_name,
             "program_name": program_name,
-            "galaxy_infrastructure_url": attribs["galaxy_infrastructure_url"],
-            "galaxy_umask": service.get("umask", "022"),
-            "galaxy_conf": config.__file__,
-            "galaxy_root": config["galaxy_root"],
+            "galaxy_infrastructure_url": config.galaxy_infrastructure_url,
+            "galaxy_umask": service.settings.get("umask") or config.umask,
+            "galaxy_conf": config.galaxy_config_file,
+            "galaxy_root": config.galaxy_root,
             "virtualenv_bin": virtualenv_bin,
-            "state_dir": self.state_dir,
+            "gravity_data_dir": config.gravity_data_dir,
         }
-        format_vars["settings"] = service.get_settings(attribs, format_vars)
+        format_vars["settings"] = service.settings
 
         # update here from PM overrides
         format_vars.update(pm_format_vars)
 
         # template the command template
         if config.service_command_style == ServiceCommandStyle.direct:
-            format_vars["command_arguments"] = service.get_command_arguments(attribs, format_vars)
+            format_vars["command_arguments"] = service.get_command_arguments(format_vars)
             format_vars["command"] = service.command_template.format(**format_vars)
 
             # template env vars
-            environment = self._service_environment(service, attribs)
+            environment = service.environment
             virtualenv_bin = format_vars["virtualenv_bin"]  # could have been changed by pm_format_vars
             if virtualenv_bin and service.add_virtualenv_to_path:
                 path = environment.get("PATH", self._service_default_path())
                 environment["PATH"] = ":".join([virtualenv_bin, path])
         else:
-            format_vars["command"] = f"galaxyctl exec {config.instance_name} {program_name}"
-            environment = {"GRAVITY_STATE_DIR": "{state_dir}"}
+            config_file = shlex.quote(config.gravity_config_file)
+            # is there a click way to do this?
+            galaxyctl = sys.argv[0]
+            if not galaxyctl.endswith("galaxyctl"):
+                warn(f"Unable to determine galaxyctl command, sys.argv[0] is: {galaxyctl}")
+            format_vars["command"] = f"{galaxyctl} --config-file {config_file} exec {config.instance_name} {service.service_name}"
+            environment = {}
         format_vars["environment"] = self._service_environment_formatter(environment, format_vars)
 
         return format_vars
 
 
 class BaseProcessManager(BaseProcessExecutionEnvironment, metaclass=ABCMeta):
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, foreground=False, **kwargs):
         super().__init__(*args, **kwargs)
         self._service_changes = None
 
+    @property
+    def _use_instance_name(self):
+        return ((not self.config_manager.single_instance)
+                or self.config_manager.get_config().instance_name != DEFAULT_INSTANCE_NAME)
+
     def _file_needs_update(self, path, contents):
         """Update if contents differ"""
         if os.path.exists(path):
@@ -169,9 +170,9 @@ def follow(self, configs=None, service_names=None, quiet=False):
             tail_popen.wait()
         else:
             if not configs:
-                configs = self.config_manager.get_registered_configs()
+                configs = self.config_manager.get_configs()
             for config in configs:
-                log_dir = config.attribs["log_dir"]
+                log_dir = config.log_dir
                 if not service_names:
                     for service in config.services:
                         program_name = self._service_program_name(config.instance_name, service)
@@ -207,7 +208,7 @@ def status(self):
         """ """
 
     @abstractmethod
-    def update(self, configs=None, service_names=None, force=False):
+    def update(self, configs=None, service_names=None, force=False, clean=False):
         """ """
 
     @abstractmethod
@@ -227,8 +228,8 @@ class ProcessExecutor(BaseProcessExecutionEnvironment):
     def _service_environment_formatter(self, environment, format_vars):
         return {k: v.format(**format_vars) for k, v in environment.items()}
 
-    def exec(self, config, service):
-        service_name = service["service_name"]
+    def exec(self, config, service, no_exec=False):
+        service_name = service.service_name
 
         # force generation of real commands
         config.service_command_style = ServiceCommandStyle.direct
@@ -242,15 +243,15 @@ def exec(self, config, service):
         info(f"Working directory: {cwd}")
         info(f"Executing: {print_env} {format_vars['command']}")
 
-        os.chdir(cwd)
-        os.execvpe(cmd[0], cmd, env)
+        if not no_exec:
+            os.chdir(cwd)
+            os.execvpe(cmd[0], cmd, env)
 
 
 class ProcessManagerRouter:
-    def __init__(self, state_dir=None, **kwargs):
-        self.config_manager = ConfigManager(state_dir=state_dir)
-        self.state_dir = self.config_manager.state_dir
-        self._load_pm_modules(state_dir=state_dir, **kwargs)
+    def __init__(self, state_dir=None, config_file=None, config_manager=None, **kwargs):
+        self.config_manager = config_manager or ConfigManager(state_dir=state_dir, config_file=config_file)
+        self._load_pm_modules(**kwargs)
         self._process_executor = ProcessExecutor(config_manager=self.config_manager)
 
     def _load_pm_modules(self, *args, **kwargs):
@@ -267,11 +268,11 @@ def _load_pm_modules(self, *args, **kwargs):
     def _instance_service_names(self, names):
         instance_names = []
         service_names = []
-        registered_instance_names = self.config_manager.get_registered_instance_names()
+        configured_instance_names = self.config_manager.get_configured_instance_names()
         configured_service_names = self.config_manager.get_configured_service_names()
         if names:
             for name in names:
-                if name in registered_instance_names:
+                if name in configured_instance_names:
                     instance_names.append(name)
                 elif name in configured_service_names | VALID_SERVICE_NAMES:
                     service_names.append(name)
@@ -281,7 +282,7 @@ def _instance_service_names(self, names):
                 exception("No provided names are known instance or service names")
         return (instance_names, service_names)
 
-    def exec(self, instance_names=None):
+    def exec(self, instance_names=None, no_exec=False):
         """ """
         instance_names, service_names = self._instance_service_names(instance_names)
 
@@ -290,19 +291,19 @@ def exec(self, instance_names=None):
         elif len(instance_names) != 1:
             exception("Only zero or one instance name can be provided")
 
-        config = self.config_manager.get_registered_configs(instances=instance_names)[0]
-        service_list = ", ".join(s["service_name"] for s in config["services"])
+        config = self.config_manager.get_configs(instances=instance_names)[0]
+        service_list = ", ".join(s.service_name for s in config.services)
 
         if len(service_names) != 1:
             exception(f"Exactly one service name must be provided. Configured service(s): {service_list}")
 
         service_name = service_names[0]
-        services = [s for s in config["services"] if s["service_name"] == service_name]
+        services = [s for s in config.services if s.service_name == service_name]
         if not services:
             exception(f"Service '{service_name}' is not configured. Configured service(s): {service_list}")
 
         service = services[0]
-        return self._process_executor.exec(config, service)
+        return self._process_executor.exec(config, service, no_exec=no_exec)
 
     @route
     def follow(self, instance_names=None, quiet=None):
@@ -329,7 +330,7 @@ def status(self):
         """ """
 
     @route_to_all
-    def update(self, instance_names=None, force=False):
+    def update(self, instance_names=None, force=False, clean=False):
         """ """
 
     @route
diff --git a/gravity/process_manager/supervisor.py b/gravity/process_manager/supervisor.py
index b4a6338..eb12bf1 100644
--- a/gravity/process_manager/supervisor.py
+++ b/gravity/process_manager/supervisor.py
@@ -6,7 +6,7 @@
 import shutil
 import subprocess
 import time
-from os.path import exists, join
+from os.path import exists, expanduser, join
 
 from gravity.io import debug, error, info, warn
 from gravity.process_manager import BaseProcessManager
@@ -69,15 +69,28 @@
 programs = {programs}
 """
 
+DEFAULT_STATE_DIR = expanduser(join("~", ".config", "galaxy-gravity"))
+if "XDG_CONFIG_HOME" in os.environ:
+    DEFAULT_STATE_DIR = join(os.environ["XDG_CONFIG_HOME"], "galaxy-gravity")
+
 
 class SupervisorProcessManager(BaseProcessManager):
 
     name = ProcessManager.supervisor
 
-    def __init__(self, state_dir=None, config_manager=None, start_daemon=True, foreground=False):
-        super().__init__(state_dir=state_dir, config_manager=config_manager)
+    def __init__(self, foreground=False, **kwargs):
+        super().__init__(**kwargs)
+
+        if self.config_manager.state_dir is not None:
+            state_dir = self.config_manager.state_dir
+        elif self.config_manager.instance_count > 1:
+            state_dir = DEFAULT_STATE_DIR
+            info(f"Supervisor configuration will be stored in {state_dir}, set --state-dir ($GRAVITY_STATE_DIR) to override")
+        else:
+            state_dir = self.config_manager.get_config().gravity_data_dir
+
         self.supervisord_exe = which("supervisord")
-        self.supervisor_state_dir = join(self.state_dir, "supervisor")
+        self.supervisor_state_dir = join(state_dir, "supervisor")
         self.supervisord_conf_path = join(self.supervisor_state_dir, "supervisord.conf")
         self.supervisord_conf_dir = join(self.supervisor_state_dir, "supervisord.conf.d")
         self.supervisord_pid_path = join(self.supervisor_state_dir, "supervisord.pid")
@@ -88,13 +101,6 @@ def __init__(self, state_dir=None, config_manager=None, start_daemon=True, foreg
         if not exists(self.supervisord_conf_dir):
             os.makedirs(self.supervisord_conf_dir)
 
-        if start_daemon:
-            self.__supervisord()
-
-    @property
-    def use_group(self):
-        return not self.config_manager.single_instance
-
     @property
     def log_file(self):
         return join(self.supervisor_state_dir, "supervisord.log")
@@ -147,25 +153,24 @@ def terminate(self):
             self.__supervisord_popen and self.__supervisord_popen.wait()
 
     def _service_program_name(self, instance_name, service):
-        if self.use_group:
-            return f"{instance_name}_{service['config_type']}_{service['service_type']}_{service['service_name']}"
+        if self._use_instance_name:
+            return f"{instance_name}_{service.config_type}_{service.service_type}_{service.service_name}"
         else:
-            return service["service_name"]
+            return service.service_name
 
     def __update_service(self, config, service, instance_conf_dir, instance_name):
-        attribs = config.attribs
         program_name = self._service_program_name(instance_name, service)
 
         # supervisor-specific format vars
         supervisor_format_vars = {
-            "log_dir": attribs["log_dir"],
-            "log_file": self._service_log_file(attribs["log_dir"], program_name),
-            "process_name_opt": f"process_name    = {service['service_name']}" if self.use_group else "",
+            "log_dir": config.log_dir,
+            "log_file": self._service_log_file(config.log_dir, program_name),
+            "process_name_opt": f"process_name    = {service.service_name}" if self._use_instance_name else "",
         }
 
         format_vars = self._service_format_vars(config, service, program_name, supervisor_format_vars)
 
-        conf = join(instance_conf_dir, f"{service['config_type']}_{service['service_type']}_{service['service_name']}.conf")
+        conf = join(instance_conf_dir, f"{service.config_type}_{service.service_type}_{service.service_name}.conf")
 
         template = SUPERVISORD_SERVICE_TEMPLATE
         contents = template.format(**format_vars)
@@ -178,7 +183,7 @@ def _process_config(self, config):
 
         Does not call ``supervisorctl update``.
         """
-        instance_name = config["instance_name"]
+        instance_name = config.instance_name
         instance_conf_dir = join(self.supervisord_conf_dir, f"{instance_name}.d")
         intended_configs = set()
         try:
@@ -188,13 +193,13 @@ def _process_config(self, config):
                 raise
 
         programs = []
-        for service in config["services"]:
+        for service in config.services:
             intended_configs.add(self.__update_service(config, service, instance_conf_dir, instance_name))
-            programs.append(f"{instance_name}_{service['config_type']}_{service['service_type']}_{service['service_name']}")
+            programs.append(f"{instance_name}_{service.config_type}_{service.service_type}_{service.service_name}")
 
         # TODO: test group mode
         group_conf = join(self.supervisord_conf_dir, f"group_{instance_name}.conf")
-        if self.use_group:
+        if self._use_instance_name:
             format_vars = {"instance_name": instance_name, "programs": ",".join(programs)}
             contents = SUPERVISORD_GROUP_TEMPLATE.format(**format_vars)
             self._update_file(group_conf, contents, instance_name, "supervisor group")
@@ -208,16 +213,18 @@ def _process_config(self, config):
             os.unlink(file)
 
         # ensure log dir exists only if configs exist
-        if intended_configs and not exists(config.attribs["log_dir"]):
-            os.makedirs(config.attribs["log_dir"])
+        if intended_configs and not exists(config.log_dir):
+            os.makedirs(config.log_dir)
 
-    def _remove_invalid_configs(self, valid_configs=None):
+    def _remove_invalid_configs(self, valid_configs=None, invalid_configs=None):
         if not valid_configs:
-            valid_configs = self.config_manager.get_registered_configs(process_manager=self.name)
+            valid_configs = self.config_manager.get_configs(process_manager=self.name)
+        if invalid_configs is not None:
+            valid_configs = [c for c in valid_configs if c not in invalid_configs]
         valid_names = [c.instance_name for c in valid_configs]
         valid_instance_dirs = [f"{name}.d" for name in valid_names]
         valid_group_confs = []
-        if self.use_group:
+        if self._use_instance_name:
             valid_group_confs = [f"group_{name}.conf" for name in valid_names]
         for entry in os.listdir(self.supervisord_conf_dir):
             path = join(self.supervisord_conf_dir, entry)
@@ -229,32 +236,31 @@ def _remove_invalid_configs(self, valid_configs=None):
                 shutil.rmtree(path)
 
     def __start_stop(self, op, configs, service_names):
-        self.update(configs=configs)
         for config in configs:
             if service_names:
-                services = [s for s in config.services if s["service_name"] in service_names]
+                services = [s for s in config.services if s.service_name in service_names]
                 for service in services:
                     program_name = self._service_program_name(config.instance_name, service)
                     self.supervisorctl(op, program_name)
             else:
-                target = f"{config.instance_name}:*" if self.use_group else "all"
+                target = f"{config.instance_name}:*" if self._use_instance_name else "all"
                 self.supervisorctl(op, target)
 
     def __reload_graceful(self, configs, service_names):
-        self.update(configs=configs)
         for config in configs:
             if service_names:
-                services = [s for s in config.services if s["service_name"] in service_names]
+                services = [s for s in config.services if s.service_name in service_names]
             else:
                 services = config.services
             for service in services:
                 program_name = self._service_program_name(config.instance_name, service)
-                if service.get_graceful_method(config["attribs"]) == GracefulMethod.SIGHUP:
+                if service.graceful_method == GracefulMethod.SIGHUP:
                     self.supervisorctl("signal", "SIGHUP", program_name)
                 else:
                     self.supervisorctl("restart", program_name)
 
     def start(self, configs=None, service_names=None):
+        self.__supervisord()
         self.__start_stop("start", configs, service_names)
         self.supervisorctl("status")
 
@@ -262,18 +268,27 @@ def stop(self, configs=None, service_names=None):
         self.__start_stop("stop", configs, service_names)
         # Exit supervisor if all processes are stopped
         supervisor = self.__get_supervisor()
-        proc_infos = supervisor.getAllProcessInfo()
-        if all([i["state"] == 0 for i in proc_infos]):
-            info("All processes stopped, supervisord will exit")
-            self.shutdown()
-        else:
-            info("Not all processes stopped, supervisord not shut down (hint: see `galaxyctl status`)")
+        if self.__supervisord_is_running():
+            proc_infos = supervisor.getAllProcessInfo()
+            if all([i["state"] == 0 for i in proc_infos]):
+                info("All processes stopped, supervisord will exit")
+                self.shutdown()
+            else:
+                info("Not all processes stopped, supervisord not shut down (hint: see `galaxyctl status`)")
 
     def restart(self, configs=None, service_names=None):
-        self.__start_stop("restart", configs, service_names)
+        if not self.__supervisord_is_running():
+            self.__supervisord()
+            warn("supervisord was not previously running; it has been started, so the 'restart' command has been ignored")
+        else:
+            self.__start_stop("restart", configs, service_names)
 
     def graceful(self, configs=None, service_names=None):
-        self.__reload_graceful(configs, service_names)
+        if not self.__supervisord_is_running():
+            self.__supervisord()
+            warn("supervisord was not previously running; it has been started, so the 'graceful' command has been ignored")
+        else:
+            self.__reload_graceful(configs, service_names)
 
     def status(self, configs=None, service_names=None):
         # TODO: create our own formatted output
@@ -288,7 +303,7 @@ def shutdown(self):
             time.sleep(0.5)
         info("supervisord has terminated")
 
-    def update(self, configs=None, force=False, **kwargs):
+    def update(self, configs=None, force=False, clean=False):
         """Add newly defined servers, remove any that are no longer present"""
         if force and os.listdir(self.supervisord_conf_dir):
             info(f"Removing supervisord conf dir due to --force option: {self.supervisord_conf_dir}")
@@ -296,8 +311,11 @@ def update(self, configs=None, force=False, **kwargs):
             os.makedirs(self.supervisord_conf_dir)
         elif not force:
             self._remove_invalid_configs(valid_configs=configs)
-        for config in configs:
-            self._process_config(config)
+        if clean:
+            self._remove_invalid_configs(invalid_configs=configs)
+        else:
+            for config in configs:
+                self._process_config(config)
         # only need to update if supervisord is running, otherwise changes will be picked up at next start
         if self.__supervisord_is_running():
             self.supervisorctl("update")
diff --git a/gravity/process_manager/systemd.py b/gravity/process_manager/systemd.py
index ce01674..4d8d64a 100644
--- a/gravity/process_manager/systemd.py
+++ b/gravity/process_manager/systemd.py
@@ -61,8 +61,8 @@ class SystemdProcessManager(BaseProcessManager):
 
     name = ProcessManager.systemd
 
-    def __init__(self, state_dir=None, start_daemon=True, foreground=False, **kwargs):
-        super(SystemdProcessManager, self).__init__(state_dir=state_dir, **kwargs)
+    def __init__(self, foreground=False, **kwargs):
+        super(SystemdProcessManager, self).__init__(**kwargs)
         self.user_mode = not self.config_manager.is_root
 
     @property
@@ -72,10 +72,6 @@ def __systemd_unit_dir(self):
             unit_path = "/etc/systemd/system" if not self.user_mode else os.path.expanduser("~/.config/systemd/user")
         return unit_path
 
-    @property
-    def __use_instance(self):
-        return not self.config_manager.single_instance
-
     def __systemctl(self, *args, ignore_rc=None, capture=False, **kwargs):
         args = list(args)
         call = subprocess.check_call
@@ -114,23 +110,22 @@ def terminate(self):
         pass
 
     def __unit_name(self, instance_name, service, unit_type="service"):
-        unit_name = f"{service['config_type']}"
-        if self.__use_instance:
+        unit_name = f"{service.config_type}"
+        if self._use_instance_name:
             unit_name += f"-{instance_name}"
         if unit_type == "service":
-            unit_name += f"-{service['service_name']}"
+            unit_name += f"-{service.service_name}"
         unit_name += f".{unit_type}"
         return unit_name
 
     def __update_service(self, config, service, instance_name):
-        attribs = config.attribs
-        program_name = service["service_name"]
+        program_name = service.service_name
         unit_name = self.__unit_name(instance_name, service)
 
         # under supervisor we expect that gravity is installed in the galaxy venv and the venv is active when gravity
         # runs, but under systemd this is not the case. we do assume $VIRTUAL_ENV is the galaxy venv if running as an
         # unprivileged user, though.
-        virtualenv_dir = attribs.get("virtualenv")
+        virtualenv_dir = config.virtualenv
         environ_virtual_env = os.environ.get("VIRTUAL_ENV")
         if not virtualenv_dir and self.user_mode and environ_virtual_env:
             warn(f"Assuming Galaxy virtualenv is value of $VIRTUAL_ENV: {environ_virtual_env}")
@@ -139,35 +134,35 @@ def __update_service(self, config, service, instance_name):
         elif not virtualenv_dir:
             exception("The `virtualenv` Gravity config option must be set when using the systemd process manager")
 
-        if self.__use_instance:
-            description = f"{config['config_type'].capitalize()} {instance_name} {program_name}"
+        if self._use_instance_name:
+            description = f"{config.config_type.capitalize()} {instance_name} {program_name}"
         else:
-            description = f"{config['config_type'].capitalize()} {program_name}"
+            description = f"{config.config_type.capitalize()} {program_name}"
+
+        memory_limit = service.settings.get("memory_limit") or config.memory_limit
+        if memory_limit:
+            memory_limit = f"MemoryLimit={memory_limit}G"
+
+        exec_reload = None
+        if service.graceful_method == GracefulMethod.SIGHUP:
+            exec_reload = "ExecReload=/bin/kill -HUP $MAINPID"
 
         # systemd-specific format vars
         systemd_format_vars = {
             "virtualenv_bin": f'{os.path.join(virtualenv_dir, "bin")}{os.path.sep}' if virtualenv_dir else "",
             "systemd_user_group": "",
-            "systemd_exec_reload": "",
-            "systemd_memory_limit": "",
+            "systemd_exec_reload": exec_reload or "",
+            "systemd_memory_limit": memory_limit or "",
             "systemd_description": description,
             "systemd_target": self.__unit_name(instance_name, config, unit_type="target"),
         }
         if not self.user_mode:
-            systemd_format_vars["systemd_user_group"] = f"User={attribs['galaxy_user']}"
-            if attribs["galaxy_group"] is not None:
-                systemd_format_vars["systemd_user_group"] += f"\nGroup={attribs['galaxy_group']}"
-
-        if service.get_graceful_method(attribs) == GracefulMethod.SIGHUP:
-            systemd_format_vars["systemd_exec_reload"] = "ExecReload=/bin/kill -HUP $MAINPID"
+            systemd_format_vars["systemd_user_group"] = f"User={config.galaxy_user}"
+            if config.galaxy_group is not None:
+                systemd_format_vars["systemd_user_group"] += f"\nGroup={config.galaxy_group}"
 
         format_vars = self._service_format_vars(config, service, program_name, systemd_format_vars)
 
-        # this is done after the superclass formatting since it accesses service settings
-        memory_limit = format_vars["settings"].get("memory_limit") or attribs["memory_limit"]
-        if memory_limit:
-            format_vars["systemd_memory_limit"] = f"MemoryLimit={memory_limit}G"
-
         if not format_vars["command"].startswith("/"):
             format_vars["command"] = f"{format_vars['virtualenv_bin']}{format_vars['command']}"
 
@@ -184,9 +179,9 @@ def follow(self, configs=None, service_names=None, quiet=False):
         u_args = [i for sl in list(zip(["-u"] * len(unit_names), unit_names)) for i in sl]
         self.__journalctl("-f", *u_args)
 
-    def _process_config(self, config, **kwargs):
+    def _process_config(self, config, clean=False, **kwargs):
         """ """
-        instance_name = config["instance_name"]
+        instance_name = config.instance_name
         intended_configs = set()
 
         try:
@@ -196,33 +191,38 @@ def _process_config(self, config, **kwargs):
                 raise
 
         service_units = []
-        for service in config["services"]:
-            conf = self.__update_service(config, service, instance_name)
-            intended_configs.add(conf)
-            service_units.append(os.path.basename(conf))
+        for service in config.services:
+            if clean:
+                intended_configs.add(os.path.join(self.__systemd_unit_dir, self.__unit_name(instance_name, service)))
+            else:
+                conf = self.__update_service(config, service, instance_name)
+                intended_configs.add(conf)
+                service_units.append(os.path.basename(conf))
 
         # create systemd target
         target_unit_name = self.__unit_name(instance_name, config, unit_type="target")
         target_conf = os.path.join(self.__systemd_unit_dir, target_unit_name)
         format_vars = {
-            "systemd_description": config["config_type"].capitalize(),
+            "systemd_description": config.config_type.capitalize(),
             "systemd_target_wants": " ".join(service_units),
         }
-        if self.__use_instance:
+        if self._use_instance_name:
             format_vars["systemd_description"] += f" {instance_name}"
         contents = SYSTEMD_TARGET_TEMPLATE.format(**format_vars)
-        updated = self._update_file(target_conf, contents, target_unit_name, "systemd unit")
+        updated = False
+        if not clean:
+            updated = self._update_file(target_conf, contents, target_unit_name, "systemd unit")
         intended_configs.add(target_conf)
         if updated:
             self.__systemctl("enable", target_conf)
 
         return intended_configs
 
-    def _process_configs(self, configs):
+    def _process_configs(self, configs, clean):
         intended_configs = set()
 
         for config in configs:
-            intended_configs = intended_configs | self._process_config(config)
+            intended_configs = intended_configs | self._process_config(config, clean=clean)
 
         # the unit dir might not exist if $GRAVITY_SYSTEMD_UNIT_PATH is set (e.g. for tests), but this is fine if there
         # are no intended configs
@@ -231,11 +231,17 @@ def _process_configs(self, configs):
 
         # FIXME: should use config_type, but that's per-service
         _present_configs = filter(
-            lambda f: f.startswith("galaxy-") and (f.endswith(".service") or f.endswith(".target")),
+            lambda f: (f.startswith("galaxy-") and (f.endswith(".service") or f.endswith(".target")) or f == "galaxy.target"),
             os.listdir(self.__systemd_unit_dir))
         present_configs = set([os.path.join(self.__systemd_unit_dir, f) for f in _present_configs])
 
-        for file in (present_configs - intended_configs):
+        # if cleaning, then intended configs are actually *unintended* configs
+        if clean:
+            unintended_configs = present_configs & intended_configs
+        else:
+            unintended_configs = present_configs - intended_configs
+
+        for file in unintended_configs:
             unit_name = os.path.basename(file)
             self.__systemctl("disable", "--now", unit_name)
             info("Removing systemd config %s", file)
@@ -251,7 +257,7 @@ def __unit_names(self, configs, service_names, use_target=True, include_services
                 if not include_services:
                     services = []
             elif service_names:
-                services = [s for s in config.services if s["service_name"] in service_names]
+                services = [s for s in config.services if s.service_name in service_names]
             unit_names.extend([self.__unit_name(config.instance_name, s) for s in services])
         return unit_names
 
@@ -287,7 +293,7 @@ def status(self, configs=None, service_names=None):
             else:
                 raise
 
-    def update(self, configs=None, force=False, **kwargs):
+    def update(self, configs=None, force=False, clean=False):
         """ """
         if force:
             for config in configs:
@@ -300,7 +306,7 @@ def update(self, configs=None, force=False, **kwargs):
                     [self.__systemctl("disable", os.path.basename(u)) for u in units]
                     list(map(os.unlink, units))
                     self._service_changes = True
-        self._process_configs(configs)
+        self._process_configs(configs, clean)
         if self._service_changes:
             self.__systemctl("daemon-reload")
         else:
@@ -308,8 +314,8 @@ def update(self, configs=None, force=False, **kwargs):
 
     def shutdown(self):
         """ """
-        if self.__use_instance:
-            configs = self.config_manager.get_registered_configs(process_manager=self.name)
+        if self._use_instance_name:
+            configs = self.config_manager.get_configs(process_manager=self.name)
             self.__systemctl("stop", *[f"galaxy-{c.instance_name}.target" for c in configs])
         else:
             self.__systemctl("stop", "galaxy.target")
diff --git a/gravity/settings.py b/gravity/settings.py
index f671f1e..6848a4f 100644
--- a/gravity/settings.py
+++ b/gravity/settings.py
@@ -7,6 +7,8 @@
 )
 from pydantic import BaseModel, BaseSettings, Extra, Field, validator
 
+DEFAULT_INSTANCE_NAME = "_default_"
+
 
 def none_to_default(cls, v, field):
     if all(
@@ -77,6 +79,7 @@ class TusdSettings(BaseModel):
 You can find a list of available hooks at https://github.com/tus/tusd/blob/master/docs/hooks.md#list-of-available-hooks.
 """)
     extra_args: str = Field(default="", description="Extra arguments to pass to tusd command line.")
+    umask: Optional[str] = Field(None, description="umask under which service should be executed")
     start_timeout: int = Field(10, description="Value of supervisor startsecs, systemd TimeoutStartSec")
     stop_timeout: int = Field(10, description="Value of supervisor stopwaitsecs, systemd TimeoutStopSec")
     memory_limit: Optional[int] = Field(
@@ -102,6 +105,7 @@ class CelerySettings(BaseModel):
     queues: str = Field("celery,galaxy.internal,galaxy.external", description="Queues to join")
     pool: Pool = Field(Pool.threads, description="Pool implementation")
     extra_args: str = Field(default="", description="Extra arguments to pass to Celery command line.")
+    umask: Optional[str] = Field(None, description="umask under which service should be executed")
     start_timeout: int = Field(10, description="Value of supervisor startsecs, systemd TimeoutStartSec")
     stop_timeout: int = Field(10, description="Value of supervisor stopwaitsecs, systemd TimeoutStopSec")
     memory_limit: Optional[int] = Field(
@@ -152,6 +156,7 @@ class GunicornSettings(BaseModel):
 Use Gunicorn's --preload option to fork workers after loading the Galaxy Application.
 Consumes less memory when multiple processes are configured. Default is ``false`` if using unicornherder, else ``true``.
 """)
+    umask: Optional[str] = Field(None, description="umask under which service should be executed")
     start_timeout: int = Field(15, description="Value of supervisor startsecs, systemd TimeoutStartSec")
     stop_timeout: int = Field(65, description="Value of supervisor stopwaitsecs, systemd TimeoutStopSec")
     memory_limit: Optional[int] = Field(
@@ -169,6 +174,59 @@ class GunicornSettings(BaseModel):
 """)
 
 
+class ReportsSettings(BaseModel):
+    enable: bool = Field(False, description="Enable Galaxy Reports server.")
+    config_file: str = Field("reports.yml", description="Path to reports.yml, relative to galaxy.yml if not absolute")
+    bind: str = Field(
+        default="localhost:9001",
+        description="The socket to bind. A string of the form: ``HOST``, ``HOST:PORT``, ``unix:PATH``, ``fd://FD``. An IP is a valid HOST.",
+    )
+    workers: int = Field(
+        default=1,
+        ge=1,
+        description="""
+Controls the number of Galaxy Reports application processes Gunicorn will spawn.
+It is not generally necessary to increase this for the low-traffic Reports server.
+""")
+    timeout: int = Field(
+        default=300,
+        ge=0,
+        description="""
+Gunicorn workers silent for more than this many seconds are killed and restarted.
+Value is a positive number or 0. Setting it to 0 has the effect of infinite timeouts by disabling timeouts for all workers entirely.
+""")
+    url_prefix: Optional[str] = Field(
+        default=None,
+        description="""
+URL prefix to serve from.
+The corresponding nginx configuration is (replace <url_prefix> and <bind> with the values from these options):
+
+location /<url_prefix>/ {
+    proxy_pass http://<bind>/;
+}
+
+If <bind> is a unix socket, you will need a ``:`` after the socket path but before the trailing slash like so:
+    proxy_pass http://unix:/run/reports.sock:/;
+""")
+    extra_args: str = Field(default="", description="Extra arguments to pass to Gunicorn command line.")
+    umask: Optional[str] = Field(None, description="umask under which service should be executed")
+    start_timeout: int = Field(10, description="Value of supervisor startsecs, systemd TimeoutStartSec")
+    stop_timeout: int = Field(10, description="Value of supervisor stopwaitsecs, systemd TimeoutStopSec")
+    memory_limit: Optional[int] = Field(
+        None,
+        description="""
+Memory limit (in GB). If the service exceeds the limit, it will be killed. Default is no limit or the value of the
+``memory_limit`` setting at the top level of the Gravity configuration, if set. Ignored if ``process_manager`` is
+``supervisor``.
+""")
+    environment: Dict[str, str] = Field(
+        default={},
+        description="""
+Extra environment variables and their values to set when running the service. A dictionary where keys are the variable
+names.
+""")
+
+
 class GxItProxySettings(BaseModel):
     enable: bool = Field(default=False, description="Set to true to start gx-it-proxy")
     ip: str = Field(default="localhost", description="Public-facing IP of the proxy")
@@ -197,6 +255,7 @@ class GxItProxySettings(BaseModel):
 Rewrite location blocks with proxy port.
 This is an advanced option that is only needed when proxying to remote interactive tool container that cannot be reached through the local network.
 """)
+    umask: Optional[str] = Field(None, description="umask under which service should be executed")
     start_timeout: int = Field(10, description="Value of supervisor startsecs, systemd TimeoutStartSec")
     stop_timeout: int = Field(10, description="Value of supervisor stopwaitsecs, systemd TimeoutStopSec")
     memory_limit: Optional[int] = Field(
@@ -221,11 +280,11 @@ class Settings(BaseSettings):
     """
 
     process_manager: ProcessManager = Field(
-        ProcessManager.supervisor,
+        None,
         description="""
 Process manager to use.
-``supervisor`` is the default process manager.
-``systemd`` is also supported.
+``supervisor`` is the default process manager when Gravity is invoked as a non-root user.
+``systemd`` is the default when Gravity is invoked as root.
 """)
 
     service_command_style: ServiceCommandStyle = Field(
@@ -234,6 +293,10 @@ class Settings(BaseSettings):
 What command to write to the process manager configs
 `gravity` (`galaxyctl exec <service-name>`) is the default
 `direct` (each service's actual command) is also supported.
+""")
+
+    umask: str = Field("022", description="""
+umask under which services should be executed. Setting ``umask`` on an individual service overrides this value.
 """)
 
     memory_limit: Optional[int] = Field(
@@ -244,6 +307,12 @@ class Settings(BaseSettings):
 is ``supervisor``.
 """)
 
+    galaxy_config_file: Optional[str] = Field(
+        None,
+        description="""
+Specify Galaxy config file (galaxy.yml), if the Gravity config is separate from the Galaxy config. Assumed to be the
+same file as the Gravity config if a ``galaxy`` key exists at the root level, otherwise, this option is required.
+""")
     galaxy_root: Optional[str] = Field(
         None,
         description="""
@@ -266,7 +335,7 @@ class Settings(BaseSettings):
         None,
         description="""
 Set to a directory that should contain log files for the processes controlled by Gravity.
-If not specified defaults to ``<state_dir>/log``.
+If not specified defaults to ``<galaxy_data_dir>/gravity/log``.
 """)
     virtualenv: Optional[str] = Field(None, description="""
 Set to Galaxy's virtualenv directory.
@@ -281,7 +350,7 @@ class Settings(BaseSettings):
 ``unicornherder`` is a production-oriented manager for (G)unicorn servers that automates zero-downtime Galaxy server restarts,
 similar to uWSGI Zerg Mode used in the past.
 """)
-    instance_name: str = Field(default="_default_", description="""Override the default instance name.
+    instance_name: str = Field(default=DEFAULT_INSTANCE_NAME, description="""Override the default instance name.
 this is hidden from you when running a single instance.""")
     gunicorn: GunicornSettings = Field(default={}, description="Configuration for Gunicorn.")
     celery: CelerySettings = Field(default={}, description="Configuration for Celery Processes.")
@@ -292,6 +361,7 @@ class Settings(BaseSettings):
 Configuration for tusd server (https://github.com/tus/tusd).
 The ``tusd`` binary must be installed manually and made available on PATH (e.g in galaxy's .venv/bin directory).
 """)
+    reports: ReportsSettings = Field(default={}, description="Configuration for Galaxy Reports.")
     handlers: Dict[str, Dict[str, Any]] = Field(
         default={},
         description="""
@@ -304,6 +374,7 @@ class Settings(BaseSettings):
     _normalize_gx_it_proxy = validator("gx_it_proxy", allow_reuse=True, pre=True)(none_to_default)
     _normalize_celery = validator("celery", allow_reuse=True, pre=True)(none_to_default)
     _normalize_tusd = validator("tusd", allow_reuse=True, pre=True)(none_to_default)
+    _normalize_reports = validator("reports", allow_reuse=True, pre=True)(none_to_default)
 
     # Require galaxy_user if running as root
     @validator("galaxy_user")
@@ -316,6 +387,15 @@ def _user_required_if_root(cls, v, values):
                 raise ValueError("Gravity cannot be run as root unless using the systemd process manager")
         return v
 
+    @validator("process_manager")
+    def _process_manager_systemd_if_root(cls, v, values):
+        if v is None:
+            if os.geteuid() == 0:
+                v = ProcessManager.systemd.value
+            else:
+                v = ProcessManager.supervisor.value
+        return v
+
     class Config:
         env_prefix = "gravity_"
         env_nested_delimiter = "."
diff --git a/gravity/state.py b/gravity/state.py
index e12cfd9..10789c2 100644
--- a/gravity/state.py
+++ b/gravity/state.py
@@ -1,20 +1,23 @@
 """ Classes to represent and manipulate gravity's stored configuration and
 state data.
 """
+from __future__ import annotations
 import enum
-import errno
 import os
 import sys
-from collections import defaultdict
+from typing import Any, Dict, List, Optional
 
-import yaml
+from pydantic import BaseModel, validator
 
-from gravity import __version__
-from gravity.io import debug
-from gravity.util import AttributeDict
+import gravity.io
+from gravity.settings import (
+    AppServer,
+    ProcessManager,
+    ServiceCommandStyle,
+)
+from gravity.util import classproperty
 
 
-GALAXY_YML_SAMPLE_PATH = "lib/galaxy/config/sample/galaxy.yml.sample"
 DEFAULT_GALAXY_ENVIRONMENT = {
     "PYTHONPATH": "lib",
     "GALAXY_CONFIG_FILE": "{galaxy_conf}",
@@ -22,43 +25,143 @@
 CELERY_BEAT_DB_FILENAME = "celery-beat-schedule"
 
 
-class GracefulMethod(enum.Enum):
-    DEFAULT = 0
-    SIGHUP = 1
+def relative_to_galaxy_root(cls, v, values):
+    if not os.path.isabs(v):
+        v = os.path.abspath(os.path.join(values["galaxy_root"], v))
+    return v
+
+
+class GracefulMethod(str, enum.Enum):
+    DEFAULT = "default"
+    SIGHUP = "sighup"
+
+
+class ConfigFile(BaseModel):
+    config_type: str
+    gravity_config_file: str
+    galaxy_config_file: str
+    instance_name: str
+    process_manager: ProcessManager
+    service_command_style: ServiceCommandStyle
+    app_server: AppServer
+    virtualenv: Optional[str]
+    galaxy_infrastructure_url: str
+    galaxy_root: Optional[str]
+    galaxy_user: Optional[str]
+    galaxy_group: Optional[str]
+    umask: Optional[str]
+    memory_limit: Optional[int]
+    gravity_data_dir: str
+    log_dir: str
+    services: List[Service] = []
 
+    @property
+    def galaxy_version(self):
+        galaxy_version_file = os.path.join(self.galaxy_root, "lib", "galaxy", "version.py")
+        with open(galaxy_version_file) as fh:
+            locs = {}
+            exec(fh.read(), {}, locs)
+            return locs["VERSION"]
+
+    @validator("galaxy_root")
+    def _galaxy_root_required(cls, v, values):
+        if v is None:
+            galaxy_config_file = values["galaxy_config_file"]
+            if os.environ.get("GALAXY_ROOT_DIR"):
+                v = os.path.abspath(os.environ["GALAXY_ROOT_DIR"])
+            elif os.path.exists(os.path.join(os.path.dirname(galaxy_config_file), os.pardir, "lib", "galaxy")):
+                v = os.path.abspath(os.path.join(os.path.dirname(galaxy_config_file), os.pardir))
+            elif galaxy_config_file.endswith(os.path.join("galaxy", "config", "sample", "galaxy.yml.sample")):
+                v = os.path.abspath(os.path.join(os.path.dirname(galaxy_config_file), os.pardir, os.pardir, os.pardir, os.pardir))
+            else:
+                gravity.io.exception(
+                    "Cannot locate Galaxy root directory: set $GALAXY_ROOT_DIR, the Gravity `galaxy_root` option, or "
+                    "`root' in the Galaxy config")
+        return v
+
+    _validate_gravity_data_dir = validator("gravity_data_dir", allow_reuse=True)(relative_to_galaxy_root)
+    _validate_log_dir = validator("log_dir", allow_reuse=True)(relative_to_galaxy_root)
+
+    def get_service(self, service_name):
+        return [s for s in self.services if s.service_name == service_name][0]
+
+    # this worked for me until it didn't, so we set exclude on the Service instead
+    # def dict(self, *args, **kwargs):
+    #     exclude = kwargs.pop("exclude", None) or {}
+    #     exclude["services"] = {-1: {"config", "service_settings"}}
+    #     return super().dict(*args, exclude=exclude, **kwargs)
+
+
+class Service(BaseModel):
+    config: ConfigFile
+    # unfortunately as a class attribute this is now excluded from dict()
+    _service_type: str = "service"
+    service_name: str = "_default_"
+    service_settings: Dict[str, Any]
 
-class Service(AttributeDict):
-    service_type = "service"
-    service_name = "_default_"
-    environment_from = None
-    settings_from = None
-    default_environment = {}
-    add_virtualenv_to_path = False
-    graceful_method = GracefulMethod.DEFAULT
-    command_arguments = {}
+    settings: Dict[str, Any] = {}
+
+    config_type: str = None
+
+    _default_environment: Dict[str, str] = {}
+    _settings_from: Optional[str] = None
+    _graceful_method: GracefulMethod = GracefulMethod.DEFAULT
+    _add_virtualenv_to_path = False
+    _command_arguments: Dict[str, str] = {}
+    _command_template: str = None
 
     def __init__(self, *args, **kwargs):
-        super(Service, self).__init__(*args, **kwargs)
-        if "service_type" not in kwargs:
-            self["service_type"] = self.__class__.service_type
-        if "service_name" not in kwargs:
-            self["service_name"] = self.__class__.service_name
+        super().__init__(*args, **kwargs)
+        self.config_type = self.config.config_type
+        # this ensures it's included in dict()
+        self.settings = self.service_settings[self.settings_from].copy()
 
-    def __eq__(self, other):
-        return self["config_type"] == other["config_type"] and self["service_type"] == other["service_type"] and self["service_name"] == other["service_name"]
+    # these are class "properties" because they are accessed by validators
+    @classproperty
+    def service_type(cls):
+        return cls._service_type
 
-    def full_match(self, other):
-        return set(self.keys()) == set(other.keys()) and all([self[k] == other[k] for k in self if not k.startswith("_")])
+    @classproperty
+    def settings_from(cls):
+        return cls._settings_from or cls.service_type
 
-    def get_graceful_method(self, attribs):
-        return self.graceful_method
+    @classproperty
+    def default_environment(cls):
+        return cls._default_environment.copy()
 
-    def get_environment(self):
-        return self.default_environment.copy()
+    # @property
+    # def settings(self):
+    #     return self.service_settings[self.settings_from].copy()
+
+    @property
+    def environment(self):
+        environment = self.default_environment
+        environment.update(self.settings.get("environment") or {})
+        return environment
 
-    def get_command_arguments(self, attribs, format_vars):
+    @property
+    def graceful_method(self):
+        return self._graceful_method
+
+    @property
+    def add_virtualenv_to_path(self):
+        return self._add_virtualenv_to_path
+
+    @property
+    def command_arguments(self):
+        return self._command_arguments
+
+    @property
+    def command_template(self):
+        return self._command_template
+
+    def __eq__(self, other):
+        return self.config_type == other.config_type and self.service_type == other.service_type and self.service_name == other.service_name
+
+    def get_command_arguments(self, format_vars):
+        """Convert settings into their command line arguments."""
         rval = {}
-        for setting, value in attribs.get(self.settings_from or self.service_type, {}).items():
+        for setting, value in self.settings.items():
             if setting in self.command_arguments:
                 # FIXME: this truthiness testing of value is probably not the best
                 if value:
@@ -69,231 +172,207 @@ def get_command_arguments(self, attribs, format_vars):
                 rval[setting] = value
         return rval
 
-    def get_settings(self, attribs, format_vars):
-        return attribs[self.settings_from or self.service_type].copy()
+    def dict(self, *args, **kwargs):
+        exclude = kwargs.pop("exclude", None) or {}
+        exclude = {"config", "service_settings"}
+        return super().dict(*args, exclude=exclude, **kwargs)
 
 
 class GalaxyGunicornService(Service):
-    service_type = "gunicorn"
+    _service_type = "gunicorn"
     service_name = "gunicorn"
-    default_environment = DEFAULT_GALAXY_ENVIRONMENT
-    command_arguments = {
+    _default_environment = DEFAULT_GALAXY_ENVIRONMENT
+    _command_arguments = {
         "preload": "--preload"
     }
-    command_template = "{virtualenv_bin}gunicorn 'galaxy.webapps.galaxy.fast_factory:factory()'" \
-                       " --timeout {settings[timeout]}" \
-                       " --pythonpath lib" \
-                       " -k galaxy.webapps.galaxy.workers.Worker" \
-                       " -b {settings[bind]}" \
-                       " --workers={settings[workers]}" \
-                       " --config python:galaxy.web_stack.gunicorn_config" \
-                       " {command_arguments[preload]}" \
-                       " {settings[extra_args]}"
-
-    # TODO: services should maybe have access to settings or attribs, and should maybe template their own command lines
-    def get_graceful_method(self, attribs):
-        if attribs["gunicorn"].get("preload"):
+    _command_template = "{virtualenv_bin}gunicorn 'galaxy.webapps.galaxy.fast_factory:factory()'" \
+                        " --timeout {settings[timeout]}" \
+                        " --pythonpath lib" \
+                        " -k galaxy.webapps.galaxy.workers.Worker" \
+                        " -b {settings[bind]}" \
+                        " --workers={settings[workers]}" \
+                        " --config python:galaxy.web_stack.gunicorn_config" \
+                        " {command_arguments[preload]}" \
+                        " {settings[extra_args]}"
+
+    @validator("service_settings")
+    def _normalize_settings(cls, v, values):
+        # TODO: should be copy?
+        settings = v[cls.settings_from]
+        if settings["preload"] is None:
+            settings["preload"] = True
+        return v
+
+    @property
+    def graceful_method(self):
+        if self.settings.get("preload"):
             return GracefulMethod.DEFAULT
         else:
             return GracefulMethod.SIGHUP
 
-    def get_environment(self):
+    @property
+    def environment(self):
         # Works around https://github.com/galaxyproject/galaxy/issues/11821
-        environment = self.default_environment.copy()
+        environment = self.default_environment
         if sys.platform == 'darwin':
             environment["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES"
+        environment.update(self.settings.get("environment", {}))
         return environment
 
 
 class GalaxyUnicornHerderService(Service):
-    service_type = "unicornherder"
+    _service_type = "unicornherder"
     service_name = "unicornherder"
-    environment_from = "gunicorn"
-    settings_from = "gunicorn"
-    graceful_method = GracefulMethod.SIGHUP
-    default_environment = DEFAULT_GALAXY_ENVIRONMENT
+    _settings_from = "gunicorn"
+    _graceful_method = GracefulMethod.SIGHUP
+    _default_environment = DEFAULT_GALAXY_ENVIRONMENT
+    _command_template = "{virtualenv_bin}unicornherder --" \
+                        " 'galaxy.webapps.galaxy.fast_factory:factory()'" \
+                        " --timeout {settings[timeout]}" \
+                        " --pythonpath lib" \
+                        " -k galaxy.webapps.galaxy.workers.Worker" \
+                        " -b {settings[bind]}" \
+                        " --workers={settings[workers]}" \
+                        " --config python:galaxy.web_stack.gunicorn_config" \
+                        " {command_arguments[preload]}" \
+                        " {settings[extra_args]}"
+
+    @validator("service_settings")
+    def _normalize_settings(cls, v, values):
+        # TODO: should be copy?
+        settings = v[cls.settings_from]
+        if settings["preload"] is None:
+            settings["preload"] = False
+        return v
+
+    environment = GalaxyGunicornService.environment
     command_arguments = GalaxyGunicornService.command_arguments
-    command_template = "{virtualenv_bin}unicornherder --pidfile {state_dir}/{program_name}.pid --" \
-                       " 'galaxy.webapps.galaxy.fast_factory:factory()'" \
-                       " --timeout {settings[timeout]}" \
-                       " --pythonpath lib" \
-                       " -k galaxy.webapps.galaxy.workers.Worker" \
-                       " -b {settings[bind]}" \
-                       " --workers={settings[workers]}" \
-                       " --config python:galaxy.web_stack.gunicorn_config" \
-                       " {command_arguments[preload]}" \
-                       " {settings[extra_args]}"
-
-    def get_environment(self):
-        environment = self.default_environment.copy()
-        if sys.platform == 'darwin':
-            environment["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES"
-        environment["GALAXY_CONFIG_LOG_DESTINATION"] = "{log_dir}/gunicorn.log"
-        return environment
 
 
 class GalaxyCeleryService(Service):
-    service_type = "celery"
+    _service_type = "celery"
     service_name = "celery"
-    default_environment = DEFAULT_GALAXY_ENVIRONMENT
-    command_template = "{virtualenv_bin}celery" \
-                       " --app galaxy.celery worker" \
-                       " --concurrency {settings[concurrency]}" \
-                       " --loglevel {settings[loglevel]}" \
-                       " --pool {settings[pool]}" \
-                       " --queues {settings[queues]}" \
-                       " {settings[extra_args]}"
+    _default_environment = DEFAULT_GALAXY_ENVIRONMENT
+    _command_template = "{virtualenv_bin}celery" \
+                        " --app galaxy.celery worker" \
+                        " --concurrency {settings[concurrency]}" \
+                        " --loglevel {settings[loglevel]}" \
+                        " --pool {settings[pool]}" \
+                        " --queues {settings[queues]}" \
+                        " {settings[extra_args]}"
 
 
 class GalaxyCeleryBeatService(Service):
-    service_type = "celery-beat"
+    _service_type = "celery-beat"
     service_name = "celery-beat"
-    settings_from = "celery"
-    default_environment = DEFAULT_GALAXY_ENVIRONMENT
-    command_template = "{virtualenv_bin}celery" \
-                       " --app galaxy.celery" \
-                       " beat" \
-                       " --loglevel {settings[loglevel]}" \
-                       " --schedule {state_dir}/" + CELERY_BEAT_DB_FILENAME
+    _settings_from = "celery"
+    _default_environment = DEFAULT_GALAXY_ENVIRONMENT
+    _command_template = "{virtualenv_bin}celery" \
+                        " --app galaxy.celery" \
+                        " beat" \
+                        " --loglevel {settings[loglevel]}" \
+                        " --schedule {gravity_data_dir}/" + CELERY_BEAT_DB_FILENAME
 
 
 class GalaxyGxItProxyService(Service):
-    service_type = "gx-it-proxy"
+    _service_type = "gx-it-proxy"
     service_name = "gx-it-proxy"
-    default_environment = {
+    _default_environment = {
         "npm_config_yes": "true",
     }
     # the npx shebang is $!/usr/bin/env node, so $PATH has to be correct
-    add_virtualenv_to_path = True
-    command_arguments = {
+    _add_virtualenv_to_path = True
+    _command_arguments = {
+        "verbose": "--verbose",
         "forward_ip": "--forwardIP {settings[forward_ip]}",
         "forward_port": "--forwardPort {settings[forward_port]}",
         "reverse_proxy": "--reverseProxy",
     }
-    command_template = "{virtualenv_bin}npx gx-it-proxy --ip {settings[ip]} --port {settings[port]}" \
-                       " --sessions {settings[sessions]} {settings[verbose]}" \
-                       " {command_arguments[forward_ip]} {command_arguments[forward_port]}" \
-                       " {command_arguments[reverse_proxy]}"
+    _command_template = "{virtualenv_bin}npx gx-it-proxy --ip {settings[ip]} --port {settings[port]}" \
+                        " --sessions {settings[sessions]} {command_arguments[verbose]}" \
+                        " {command_arguments[forward_ip]} {command_arguments[forward_port]}" \
+                        " {command_arguments[reverse_proxy]}"
 
 
 class GalaxyTUSDService(Service):
-    service_type = "tusd"
+    _service_type = "tusd"
     service_name = "tusd"
-    command_template = "{settings[tusd_path]} -host={settings[host]} -port={settings[port]}" \
-                       " -upload-dir={settings[upload_dir]}" \
-                       " -hooks-http={galaxy_infrastructure_url}/api/upload/hooks" \
-                       " -hooks-http-forward-headers=X-Api-Key,Cookie {settings[extra_args]}" \
-                       " -hooks-enabled-events {settings[hooks_enabled_events]}"
+    _command_template = "{settings[tusd_path]} -host={settings[host]} -port={settings[port]}" \
+                        " -upload-dir={settings[upload_dir]}" \
+                        " -hooks-http={galaxy_infrastructure_url}/api/upload/hooks" \
+                        " -hooks-http-forward-headers=X-Api-Key,Cookie {settings[extra_args]}" \
+                        " -hooks-enabled-events {settings[hooks_enabled_events]}"
+
+    @validator("service_settings")
+    def _validate_settings(cls, v, values):
+        if not values["config"].galaxy_infrastructure_url:
+            gravity.io.exception("To run the tusd server you need to set galaxy_infrastructure_url in the galaxy section of galaxy.yml")
+        return v
+
+
+class GalaxyReportsService(Service):
+    _service_type = "reports"
+    service_name = "reports"
+    _graceful_method = GracefulMethod.SIGHUP
+    _default_environment = {
+        "PYTHONPATH": "lib",
+        "GALAXY_REPORTS_CONFIG": "{settings[config_file]}",
+    }
+    _command_arguments = {
+        "url_prefix": "--env SCRIPT_NAME={settings[url_prefix]}",
+    }
+    _command_template = "{virtualenv_bin}gunicorn 'galaxy.webapps.reports.fast_factory:factory()'" \
+                        " --timeout {settings[timeout]}" \
+                        " --pythonpath lib" \
+                        " -k uvicorn.workers.UvicornWorker" \
+                        " -b {settings[bind]}" \
+                        " --workers={settings[workers]}" \
+                        " --config python:galaxy.web_stack.gunicorn_config" \
+                        " {command_arguments[url_prefix]}" \
+                        " {settings[extra_args]}"
+
+    @validator("service_settings")
+    def _validate_settings(cls, v, values):
+        settings = v[cls.settings_from]
+        reports_config_file = settings["config_file"]
+        if not os.path.isabs(reports_config_file):
+            reports_config_file = os.path.join(os.path.dirname(values["config"]["galaxy_config_file"]), reports_config_file)
+        if not os.path.exists(reports_config_file):
+            gravity.io.exception(f"Reports enabled but reports config file does not exist: {reports_config_file}")
+        settings["config_file"] = reports_config_file
+        return v
 
 
 class GalaxyStandaloneService(Service):
-    service_type = "standalone"
+    _service_type = "standalone"
     service_name = "standalone"
-    default_start_timeout = 20
-    default_stop_timeout = 65
-    command_template = "{virtualenv_bin}python ./lib/galaxy/main.py -c {galaxy_conf} --server-name={server_name}" \
-                       " {command_arguments[attach_to_pool]} {command_arguments[pid_file]}"
-
-    def get_environment(self):
-        return self.get("environment") or {}
+    # TODO: add these to Galaxy docs, test that they are settable in all the ways they should be
+    _default_settings = {
+        "start_timeout": 20,
+        "stop_timeout": 65,
+    }
+    _command_template = "{virtualenv_bin}python ./lib/galaxy/main.py -c {galaxy_conf} --server-name={server_name}" \
+                        " {command_arguments[attach_to_pool]}"
 
-    def get_command_arguments(self, attribs, format_vars):
-        # full override because standalone doesn't have settings
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # overwrite the default method of setting settings, we need to include defaults sinc standalone does not have
+        # gravity settings
+        self.settings = self._default_settings.copy()
+        self.settings.update(self.service_settings[self.settings_from])
+
+    def get_command_arguments(self, format_vars):
+        # full override to do the join
         command_arguments = {
             "attach_to_pool": "",
-            "pid_file": " --pid-file={state_dir}/{program_name}.pid".format(**format_vars),
         }
-        server_pools = self.get("server_pools")
+        server_pools = self.settings.get("server_pools")
         if server_pools:
             _attach_to_pool = " ".join(f"--attach-to-pool={server_pool}" for server_pool in server_pools)
             # Insert a single leading space
             command_arguments["attach_to_pool"] = f" {_attach_to_pool}"
         return command_arguments
 
-    def get_settings(self, attribs, format_vars):
-        return {
-            "start_timeout": self.start_timeout or self.default_start_timeout,
-            "stop_timeout": self.stop_timeout or self.default_stop_timeout,
-        }
-
-
-class ConfigFile(AttributeDict):
-    persist_keys = (
-        "config_type",
-        "instance_name",
-        "galaxy_root",
-    )
-
-    def __init__(self, *args, **kwargs):
-        super(ConfigFile, self).__init__(*args, **kwargs)
-        services = []
-        for service in self.get("services", []):
-            service_class = SERVICE_CLASS_MAP.get(service["service_type"], Service)
-            services.append(service_class(**service))
-        self.services = services
-
-    @property
-    def defaults(self):
-        return {
-            "process_manager": self["process_manager"],
-            "instance_name": self["instance_name"],
-            "galaxy_root": self["galaxy_root"],
-            "log_dir": self["attribs"]["log_dir"],
-            "gunicorn":  self.gunicorn_config,
-        }
-
-    @property
-    def gunicorn_config(self):
-        # We used to store bind_address and bind_port instead of a gunicorn config key, so restore from here
-        gunicorn = self["attribs"].get("gunicorn")
-        if not gunicorn and 'bind_address' in self["attribs"]:
-            return {'bind': f'{self["attribs"]["bind_address"]}:{self["attribs"]["bind_port"]}'}
-        return gunicorn
-
-
-class GravityState(AttributeDict):
-    init_contents = {
-        "gravity_version": __version__,
-        "config_files": {},
-    }
-
-    @classmethod
-    def open(cls, name):
-        try:
-            s = cls.loads(open(name).read())
-        except (OSError, IOError) as exc:
-            if exc.errno == errno.ENOENT:
-                debug(f"Initializing Gravity config state: {name}")
-                yaml.dump(GravityState.init_contents, open(name, "w"))
-                s = cls(**GravityState.init_contents)
-        s._name = name
-        return s
-
-    def __init__(self, *args, **kwargs):
-        super(GravityState, self).__init__(*args, **kwargs)
-        normalized_state = defaultdict(dict)
-        for config_file, config_dict in self["config_files"].items():
-            # resolve path, so we always deal with absolute and symlink-resolved paths
-            config_file = os.path.realpath(config_file)
-            if config_file.endswith(GALAXY_YML_SAMPLE_PATH):
-                root_dir = config_dict['galaxy_root']
-                non_sample_path = os.path.join(root_dir, 'config', 'galaxy.yml')
-                if os.path.exists(non_sample_path):
-                    config_file = non_sample_path
-            normalized_state["config_files"][config_file] = ConfigFile(config_dict)
-        self.update(normalized_state)
-
-    def __enter__(self):
-        return self
-
-    def __exit__(self, exc_type, exc_value, traceback):
-        self["gravity_version"] = __version__
-        with open(self._name, "w") as fh:
-            self.dump(fh)
-
-    def set_name(self, name):
-        self._name = name
-
 
 def service_for_service_type(service_type):
     try:
@@ -310,6 +389,7 @@ def service_for_service_type(service_type):
     "celery-beat": GalaxyCeleryBeatService,
     "gx-it-proxy": GalaxyGxItProxyService,
     "tusd": GalaxyTUSDService,
+    "reports": GalaxyReportsService,
     "standalone": GalaxyStandaloneService,
 }
 
diff --git a/gravity/util/__init__.py b/gravity/util/__init__.py
index 0562f0d..add247d 100644
--- a/gravity/util/__init__.py
+++ b/gravity/util/__init__.py
@@ -4,65 +4,15 @@
 import copy
 import os
 import sys
+import yaml
 
 import jsonref
-import ruamel.yaml
-import yaml
 from gravity.settings import Settings
 
 
-class SafeLoaderWithInclude(yaml.SafeLoader):
-    def __init__(self, stream):
-        self.__config_dir = os.path.dirname(stream.name)
-        super().__init__(stream)
-
-    def include(self, node):
-        included = os.path.join(self.__config_dir, self.construct_scalar(node))
-        with open(included) as fh:
-            return yaml.load(fh, SafeLoaderWithInclude)
-
-
-SafeLoaderWithInclude.add_constructor('!include', SafeLoaderWithInclude.include)
-
-
-def yaml_safe_load_with_include(stream):
-    return yaml.load(stream, SafeLoaderWithInclude)
-
-
-class AttributeDict(dict):
-    yaml_tag = "tag:yaml.org,2002:map"
-
-    @classmethod
-    def loads(cls, s, *args, **kwargs):
-        return cls(yaml.safe_load(s, *args, **kwargs))
-
-    @classmethod
-    def to_yaml(cls, representer, node):
-        d = {}
-        for k in node.keys():
-            if not k.startswith("_"):
-                d[k] = node[k]
-        return representer.represent_mapping(cls.yaml_tag, d)
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self._yaml = ruamel.yaml.YAML()
-        self._yaml.register_class(self.__class__)
-
-    def __eq__(self, other):
-        return all([other[k] == v for k, v in self.items() if not k.startswith("_")])
-
-    def __setattr__(self, name, value):
-        self[name] = value
-
-    def __getattr__(self, name):
-        try:
-            return self[name]
-        except KeyError:
-            raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
-
-    def dump(self, fp, *args, **kwargs):
-        self._yaml.dump(self, fp)
+class classproperty(property):
+    def __get__(self, cls, owner):
+        return classmethod(self.fget).__get__(None, owner)()
 
 
 def recursive_update(to_update, update_from):
diff --git a/setup.py b/setup.py
index f3b00e8..a0bf0d2 100644
--- a/setup.py
+++ b/setup.py
@@ -37,7 +37,7 @@ def get_var(var_name):
     license="MIT",
     keywords="gravity galaxy",
     python_requires=">=3.6",
-    install_requires=["Click", "supervisor", "pyyaml", "ruamel.yaml", "packaging", "pydantic", "jsonref"],
+    install_requires=["Click", "supervisor", "pyyaml", "packaging", "pydantic", "jsonref"],
     entry_points={"console_scripts": [
         "galaxy = gravity.cli:galaxy",
         "galaxyctl = gravity.cli:galaxyctl",


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 3aef666..1a0af5d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,5 @@
 import os
+import glob
 import signal
 import shutil
 import socket
@@ -16,6 +17,7 @@
 gravity:
   process_manager: {process_manager_name}
   service_command_style: direct
+  instance_name: {instance_name}
   gunicorn:
     bind: 'localhost:{gx_port}'
   gx_it_proxy:
@@ -32,69 +34,6 @@
 """
 
 
-# old-style string formatting is used because of curly braces in the YAML
-CONFIGSTATE_YAML_0_X = """
-config_files:
-  %(galaxy_yml)s:
-    config_type: galaxy
-    instance_name: gravity-0-x
-    attribs:
-      galaxy_infrastructure_url: ''
-      app_server: gunicorn
-      log_dir: %(state_dir)s/log
-      virtualenv:
-      gunicorn:
-        enable: true
-        bind: localhost:8080
-        workers: 1
-        timeout: 300
-        extra_args: ''
-        preload: true
-        environment: {}
-      tusd:
-        enable: false
-        tusd_path: tusd
-        host: localhost
-        port: 1080
-        upload_dir: ''
-        hooks_enabled_events: pre-create
-        extra_args: ''
-        environment: {}
-      celery:
-        enable: true
-        enable_beat: true
-        concurrency: 2
-        loglevel: DEBUG
-        queues: celery,galaxy.internal,galaxy.external
-        pool: threads
-        extra_args: ''
-        environment: {}
-      handlers: {}
-      gravity_version: 0.13.4
-      galaxy_root: %(galaxy_root_dir)s
-      gx_it_proxy:
-        enable: false
-        ip: localhost
-        port: 4002
-        sessions: database/interactivetools_map.sqlite
-        verbose: true
-        forward_ip:
-        forward_port:
-        reverse_proxy: false
-        environment: {}
-    services:
-    - config_type: galaxy
-      service_type: gunicorn
-      service_name: gunicorn
-    - config_type: galaxy
-      service_type: celery
-      service_name: celery
-    - config_type: galaxy
-      service_type: celery-beat
-      service_name: celery-beat
-"""
-
-
 @pytest.fixture(scope='session')
 def galaxy_git_dir():
     galaxy_dir = TEST_DIR / 'galaxy.git'
@@ -122,9 +61,11 @@ def galaxy_yml(galaxy_root_dir):
 
 
 @pytest.fixture()
-def state_dir():
-    directory = tempfile.mkdtemp()
-    os.environ['GRAVITY_SYSTEMD_UNIT_PATH'] = f"/run/user/{os.getuid()}/systemd/user"
+def state_dir(monkeypatch):
+    directory = tempfile.mkdtemp(prefix="gravity_test")
+    unit_path = f"/run/user/{os.getuid()}/systemd/user"
+    monkeypatch.setenv("GRAVITY_STATE_DIR", directory)
+    monkeypatch.setenv("GRAVITY_SYSTEMD_UNIT_PATH", unit_path)
     try:
         yield Path(directory)
     finally:
@@ -133,6 +74,16 @@ def state_dir():
         except Exception:
             pass
         shutil.rmtree(directory)
+        instance_name = os.path.basename(directory)
+        unit_paths = glob.glob(os.path.join(unit_path, f"galaxy-{instance_name}*"))
+        if unit_paths:
+            units = list(map(os.path.basename, unit_paths))
+            try:
+                subprocess.check_call(["systemctl", "--user", "stop", *units])
+                list(map(os.unlink, unit_paths))
+                subprocess.check_call(["systemctl", "--user", "daemon-reload"])
+            except Exception:
+                subprocess.check_call(["systemctl", "--user", "list-units", "--all", "galaxy*"])
 
 
 @pytest.fixture
@@ -141,15 +92,6 @@ def default_config_manager(state_dir):
         yield cm
 
 
-@pytest.fixture
-def configstate_yaml_0_x(galaxy_root_dir, state_dir, galaxy_yml):
-    return CONFIGSTATE_YAML_0_X % {
-        "galaxy_root_dir": galaxy_root_dir,
-        "state_dir": state_dir,
-        "galaxy_yml": str(galaxy_yml)
-    }
-
-
 @pytest.fixture()
 def job_conf(request, galaxy_root_dir):
     conf = yaml.safe_load(request.param)
@@ -176,9 +118,10 @@ def free_port():
 
 
 @pytest.fixture()
-def startup_config(galaxy_virtualenv, free_port):
+def startup_config(state_dir, galaxy_virtualenv, free_port):
     return {
         'gravity': {
+            'service_command_style': 'direct',
             'virtualenv': galaxy_virtualenv,
             'gunicorn': {
                 'bind': f'localhost:{free_port}'}
@@ -189,20 +132,60 @@ def startup_config(galaxy_virtualenv, free_port):
     }
 
 
+@pytest.fixture()
+def reports_config(galaxy_root_dir, galaxy_virtualenv, free_port):
+    return {
+        'gravity': {
+            'service_command_style': 'direct',
+            'virtualenv': galaxy_virtualenv,
+            'gunicorn': {'enable': False},
+            'celery': {
+                'enable': False,
+                'enable_beat': False,
+            },
+            'reports': {
+                'enable': True,
+                'bind': f'localhost:{free_port}',
+                'config_file': str(galaxy_root_dir / "config" / "reports.yml.sample"),
+            }
+        }
+    }
+
+
+@pytest.fixture()
+def non_default_config():
+    return {
+        'galaxy': None,
+        'gravity': {
+            'service_command_style': 'direct',
+            'gunicorn': {
+                'bind': 'localhost:8081',
+                'environment': {'FOO': 'foo'}
+            },
+            'celery': {
+                'concurrency': 4
+            }
+        }
+    }
+
+
 @pytest.fixture
-def gxit_config(free_port, another_free_port, process_manager_name):
+def gxit_config(state_dir, free_port, another_free_port, process_manager_name):
     config_yaml = GXIT_CONFIG.format(
         gxit_port=another_free_port,
         gx_port=free_port,
-        process_manager_name=process_manager_name)
+        process_manager_name=process_manager_name,
+        instance_name=os.path.basename(state_dir),
+    )
     return yaml.safe_load(config_yaml)
 
 
 @pytest.fixture
-def tusd_config(startup_config, free_port, another_free_port, process_manager_name):
+def tusd_config(state_dir, startup_config, free_port, another_free_port, process_manager_name):
     startup_config["gravity"] = {
         "process_manager": process_manager_name,
         "service_command_style": "direct",
+        "instance_name": os.path.basename(state_dir),
         "tusd": {"enable": True, "port": another_free_port, "upload_dir": "/tmp"}}
     startup_config["galaxy"]["galaxy_infrastructure_url"] = f"http://localhost:{free_port}"
     return startup_config
diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py
index 4669826..2c3cab9 100644
--- a/tests/test_config_manager.py
+++ b/tests/test_config_manager.py
@@ -1,31 +1,34 @@
 import json
 from pathlib import Path
 
-from gravity import __version__, config_manager
+import pytest
+
+from gravity import config_manager
 from gravity.settings import Settings
 from gravity.state import GracefulMethod
 
 
-def test_register_defaults(galaxy_yml, galaxy_root_dir, state_dir, default_config_manager):
-    default_config_manager.add([str(galaxy_yml)])
-    assert str(galaxy_yml) in default_config_manager.state['config_files']
-    config = default_config_manager.get_registered_config(str(galaxy_yml))
+def test_load_defaults(galaxy_yml, galaxy_root_dir, state_dir, default_config_manager):
+    default_config_manager.load_config_file(str(galaxy_yml))
+    config = default_config_manager.get_config()
     default_settings = Settings()
-    assert config['config_type'] == 'galaxy'
-    assert config['instance_name'] == default_settings.instance_name
-    assert config['services'] != []
-    attributes = config['attribs']
-    assert attributes['app_server'] == 'gunicorn'
-    assert Path(attributes['log_dir']) == Path(state_dir) / 'log'
-    assert Path(config['galaxy_root']) == galaxy_root_dir
-    gunicorn_attributes = attributes['gunicorn']
-    assert gunicorn_attributes['bind'] == default_settings.gunicorn.bind
-    assert gunicorn_attributes['workers'] == default_settings.gunicorn.workers
-    assert gunicorn_attributes['timeout'] == default_settings.gunicorn.timeout
-    assert gunicorn_attributes['extra_args'] == default_settings.gunicorn.extra_args
-    assert gunicorn_attributes['preload'] is True
-    assert attributes['celery'] == default_settings.celery.dict()
-    assert attributes["tusd"] == default_settings.tusd.dict()
+    assert config.config_type == 'galaxy'
+    assert config.process_manager == 'supervisor'
+    assert config.instance_name == default_settings.instance_name
+    assert config.services != []
+    assert config.app_server == 'gunicorn'
+    assert Path(config.log_dir) == Path(state_dir) / 'log'
+    assert Path(config.galaxy_root) == galaxy_root_dir
+    gunicorn_settings = config.get_service('gunicorn').settings
+    assert gunicorn_settings['bind'] == default_settings.gunicorn.bind
+    assert gunicorn_settings['workers'] == default_settings.gunicorn.workers
+    assert gunicorn_settings['timeout'] == default_settings.gunicorn.timeout
+    assert gunicorn_settings['extra_args'] == default_settings.gunicorn.extra_args
+    assert gunicorn_settings['preload'] is True
+    celery_settings = config.get_service('celery').settings
+    assert celery_settings == default_settings.celery.dict()
+    with pytest.raises(IndexError):
+        config.get_service('tusd')
 
 
 def test_preload_default(galaxy_yml, default_config_manager):
@@ -36,92 +39,63 @@ def test_preload_default(galaxy_yml, default_config_manager):
             'app_server': app_server
         }
     }))
-    default_config_manager.add([str(galaxy_yml)])
-    config = default_config_manager.get_registered_config(str(galaxy_yml))
-    gunicorn_attributes = config['attribs']['gunicorn']
-    assert gunicorn_attributes['preload'] is False
-
-
-def test_register_non_default(galaxy_yml, default_config_manager):
-    new_bind = 'localhost:8081'
-    environment = {'FOO': 'foo'}
-    concurrency = 4
-    galaxy_yml.write(json.dumps({
-        'galaxy': None,
-        'gravity': {
-            'gunicorn': {
-                'bind': new_bind,
-                'environment': environment
-            },
-            'celery': {
-                'concurrency': concurrency
-            }
-        }
-    }))
-    default_config_manager.add([str(galaxy_yml)])
-    config = default_config_manager.get_registered_config(str(galaxy_yml))
-    gunicorn_attributes = config['attribs']['gunicorn']
-    assert gunicorn_attributes['bind'] == new_bind
-    assert gunicorn_attributes['environment'] == environment
+    default_config_manager.load_config_file(str(galaxy_yml))
+    config = default_config_manager.get_config()
+    unicornherder_settings = config.get_service('unicornherder').settings
+    assert unicornherder_settings['preload'] is False
+
+
+def test_load_non_default(galaxy_yml, default_config_manager, non_default_config):
+    if default_config_manager.instance_count == 0:
+        galaxy_yml.write(json.dumps(non_default_config))
+        default_config_manager.load_config_file(str(galaxy_yml))
+    config = default_config_manager.get_config()
+    gunicorn_settings = config.get_service('gunicorn').settings
+    assert gunicorn_settings['bind'] == non_default_config['gravity']['gunicorn']['bind']
+    assert gunicorn_settings['environment'] == non_default_config['gravity']['gunicorn']['environment']
     default_settings = Settings()
-    assert gunicorn_attributes['workers'] == default_settings.gunicorn.workers
-    celery_attributes = config['attribs']['celery']
-    assert celery_attributes['concurrency'] == concurrency
-
+    assert gunicorn_settings['workers'] == default_settings.gunicorn.workers
+    celery_settings = config.get_service('celery').settings
+    assert celery_settings['concurrency'] == non_default_config['gravity']['celery']['concurrency']
 
-def test_deregister(galaxy_yml, default_config_manager):
-    default_config_manager.add([str(galaxy_yml)])
-    assert str(galaxy_yml) in default_config_manager.state['config_files']
-    assert default_config_manager.is_registered(str(galaxy_yml))
-    default_config_manager.remove([str(galaxy_yml)])
-    assert str(galaxy_yml) not in default_config_manager.state['config_files']
-    assert not default_config_manager.is_registered(str(galaxy_yml))
 
+def test_split_config(galaxy_yml, galaxy_root_dir, default_config_manager, non_default_config):
+    default_config_file = str(galaxy_root_dir / "config" / "galaxy.yml.sample")
+    non_default_config['gravity']['galaxy_config_file'] = default_config_file
+    del non_default_config['galaxy']
+    galaxy_yml.write(json.dumps(non_default_config))
+    default_config_manager.load_config_file(str(galaxy_yml))
+    test_load_non_default(galaxy_yml, default_config_manager, non_default_config)
+    config = default_config_manager.get_config()
+    assert config.gravity_config_file == str(galaxy_yml)
+    assert config.galaxy_config_file == default_config_file
 
-def test_rename(galaxy_root_dir, state_dir, default_config_manager):
-    galaxy_yml_sample = galaxy_root_dir / "config" / "galaxy.yml.sample"
-    default_config_manager.add([str(galaxy_yml_sample)])
-    galaxy_yml = galaxy_root_dir / "config" / "galaxy123.yml"
-    galaxy_yml_sample.copy(galaxy_yml)
-    assert default_config_manager.is_registered(str(galaxy_yml_sample.realpath()))
-    assert not default_config_manager.is_registered(str(galaxy_yml))
-    default_config_manager.rename(str(galaxy_yml_sample.realpath()), str(galaxy_yml))
-    assert not default_config_manager.is_registered(str(galaxy_yml_sample.realpath()))
-    assert default_config_manager.is_registered(str(galaxy_yml))
 
-
-def test_auto_register(galaxy_yml, default_config_manager, monkeypatch):
+def test_auto_load_env_var(galaxy_yml, default_config_manager, monkeypatch):
     monkeypatch.setenv("GALAXY_CONFIG_FILE", str(galaxy_yml))
-    assert not default_config_manager.is_registered(str(galaxy_yml))
-    default_config_manager.auto_register()
-    assert default_config_manager.is_registered(str(galaxy_yml))
+    assert default_config_manager.instance_count == 0
+    default_config_manager.auto_load()
+    assert default_config_manager.is_loaded(galaxy_yml)
 
 
-def test_register_sample_update_to_non_sample(galaxy_root_dir, state_dir, default_config_manager):
+def test_auto_load_root_dir(galaxy_root_dir, monkeypatch):
+    monkeypatch.chdir(galaxy_root_dir)
     galaxy_yml_sample = galaxy_root_dir / "config" / "galaxy.yml.sample"
-    default_config_manager.add([str(galaxy_yml_sample)])
+    with config_manager.config_manager() as cm:
+        assert cm.instance_count == 1
+        assert cm.is_loaded(galaxy_yml_sample)
     galaxy_yml = galaxy_root_dir / "config" / "galaxy.yml"
     galaxy_yml_sample.copy(galaxy_yml)
-    default_config_manager.instance_count == 1
-    assert default_config_manager.get_registered_config(str(galaxy_yml))
-
-
-def test_convert_0_x_config(state_dir, galaxy_yml, configstate_yaml_0_x):
-    configstate_yaml = state_dir / "configstate.yaml"
-    open(configstate_yaml, "w").write(configstate_yaml_0_x)
-    with config_manager.config_manager(state_dir=state_dir) as cm:
-        assert cm.state.gravity_version == __version__
-        config = cm.state.config_files[str(galaxy_yml)]
-        assert config.config_type == "galaxy"
-        assert config.instance_name == "gravity-0-x"
-        assert "attribs" not in config
+    with config_manager.config_manager() as cm:
+        assert cm.instance_count == 1
+        assert cm.is_loaded(galaxy_yml)
+    galaxy_yml.remove()
 
 
 def test_gunicorn_graceful_method_preload(galaxy_yml, default_config_manager):
-    default_config_manager.add([str(galaxy_yml)])
-    config = default_config_manager.get_registered_config(str(galaxy_yml))
-    gunicorn_service = [s for s in config["services"] if s["service_name"] == "gunicorn"][0]
-    graceful_method = gunicorn_service.get_graceful_method(config["attribs"])
+    default_config_manager.load_config_file(str(galaxy_yml))
+    config = default_config_manager.get_config()
+    graceful_method = config.get_service('gunicorn').graceful_method
     assert graceful_method == GracefulMethod.DEFAULT
 
 
@@ -130,10 +104,9 @@ def test_gunicorn_graceful_method_no_preload(galaxy_yml, default_config_manager)
         {'galaxy': None, 'gravity': {
             'gunicorn': {'preload': False}}}
     ))
-    default_config_manager.add([str(galaxy_yml)])
-    config = default_config_manager.get_registered_config(str(galaxy_yml))
-    gunicorn_service = [s for s in config["services"] if s["service_name"] == "gunicorn"][0]
-    graceful_method = gunicorn_service.get_graceful_method(config["attribs"])
+    default_config_manager.load_config_file(str(galaxy_yml))
+    config = default_config_manager.get_config()
+    graceful_method = config.get_service('gunicorn').graceful_method
     assert graceful_method == GracefulMethod.SIGHUP
 
 
diff --git a/tests/test_operations.py b/tests/test_operations.py
index 01e817c..e0f3924 100644
--- a/tests/test_operations.py
+++ b/tests/test_operations.py
@@ -1,8 +1,8 @@
 import json
 import os
 import re
+import subprocess
 import time
-from pathlib import Path
 
 import pytest
 import requests
@@ -15,45 +15,45 @@
 STARTUP_TIMEOUT = 30
 CELERY_BEAT_TIMEOUT = 10
 # celery.beat.PersistentScheduler uses shelve, which can append a suffix based on which db backend is used
-CELERY_BEAT_DB_FILENAMES = map(lambda ext: CELERY_BEAT_DB_FILENAME + ext, ('', '.db', '.dat', '.bak', '.dir'))
-
-
-def test_cmd_register(state_dir, galaxy_yml):
-    runner = CliRunner()
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'register', str(galaxy_yml)])
-    assert result.exit_code == 0, result.output
-    assert 'Registered galaxy config:' in result.output
-
-
-def test_cmd_deregister(state_dir, galaxy_yml):
-    test_cmd_register(state_dir, galaxy_yml)
-    runner = CliRunner()
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'deregister', str(galaxy_yml)])
-    assert result.exit_code == 0, result.output
-    assert 'Deregistered config:' in result.output
-
-
-def wait_for_startup(state_dir, free_port, prefix="/"):
+CELERY_BEAT_DB_FILENAMES = list(map(lambda ext: CELERY_BEAT_DB_FILENAME + ext, ('', '.db', '.dat', '.bak', '.dir')))
+
+
+def log_for_service(state_dir, process_manager_name, start_time, service_name, instance_name=None):
+    if process_manager_name == "systemd":
+        # instance_name should never be none in the systemd case
+        log_name = f"galaxy-{instance_name}-{service_name}"
+        cmd = f"journalctl --user --no-pager --since=@{start_time} --unit={log_name}.service".split()
+        return subprocess.check_output(cmd, text=True)
+    else:
+        # could probably just glob here
+        if instance_name is not None:
+            log_name = f"{instance_name}_galaxy_{service_name}_{service_name}.log"
+        else:
+            log_name = f"{service_name}.log"
+        path = state_dir / "log" / log_name
+        with open(path) as fh:
+            return fh.read()
+
+
+def wait_for_startup(state_dir, free_port, prefix="/", path="/api/version", service_name="gunicorn",
+                     process_manager_name="supervisor", start_time=None, instance_name=None):
     for _ in range(STARTUP_TIMEOUT * 4):
         try:
-            requests.get(f"http://localhost:{free_port}{prefix}api/version").raise_for_status()
-            return True
+            requests.get(f"http://localhost:{free_port}{prefix.rstrip('/')}{path}").raise_for_status()
+            return True, ""
         except Exception:
             time.sleep(0.25)
-    with open(state_dir / "log" / 'gunicorn.log') as fh:
-        startup_logs = fh.read()
-    return startup_logs
-
-
-def wait_for_gxit_proxy(state_dir):
-    startup_logs = ""
-    with open(state_dir / "log" / 'gx-it-proxy.log') as fh:
-        for _ in range(STARTUP_TIMEOUT * 4):
-            startup_logs = f"{startup_logs}{fh.read()}"
-            if 'Watching path' in startup_logs:
-                return True
-            time.sleep(0.25)
-    return startup_logs
+    return False, log_for_service(state_dir, process_manager_name, start_time, service_name, instance_name=instance_name)
+
+
+def wait_for_gxit_proxy(state_dir, process_manager_name, start_time):
+    instance_name = os.path.basename(state_dir)
+    for _ in range(STARTUP_TIMEOUT * 4):
+        startup_logs = log_for_service(state_dir, process_manager_name, start_time, service_name="gx-it-proxy", instance_name=instance_name)
+        if 'Watching path' in startup_logs:
+            return True, ""
+        time.sleep(0.25)
+    return False, startup_logs
 
 
 def wait_for_any_path(paths, timeout):
@@ -66,125 +66,106 @@ def wait_for_any_path(paths, timeout):
     return False
 
 
-def start_instance(state_dir, free_port):
+def start_instance(state_dir, galaxy_yml, free_port, process_manager_name="supervisor", instance_name=None):
     runner = CliRunner()
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'start'])
-    assert re.search(r"gunicorn\s*STARTING", result.output)
-    assert result.exit_code == 0, result.output
-    startup_done = wait_for_startup(state_dir, free_port)
-    assert startup_done is True, f"Startup failed. Application startup logs:\n {startup_done}"
-
-
-def test_cmd_start(state_dir, galaxy_yml, startup_config, free_port):
+    start_time = time.time()
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'start'])
+    assert result.exit_code == 0, result.output
+    if process_manager_name == "systemd":
+        gunicorn_name = f"galaxy-{instance_name}-gunicorn"
+        output = subprocess.check_output(f"systemctl --user status {gunicorn_name}.service".split(), text=True)
+        assert f"● {gunicorn_name}.service" in output
+    else:
+        assert re.search(r"gunicorn\s*STARTING", result.output)
+    startup_done, startup_logs = wait_for_startup(state_dir, free_port, process_manager_name=process_manager_name,
+                                                  start_time=start_time, instance_name=instance_name)
+    assert startup_done is True, f"Startup failed. Application startup logs:\n {startup_logs}"
+
+
+@pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
+def test_cmd_start(state_dir, galaxy_yml, startup_config, free_port, process_manager_name):
+    # TODO: test service_command_style = gravity, doesn't work when you're using CliRunner, which just imports the cli
+    # rather than the entry point existing on the filesystem somewhere.
+    instance_name = os.path.basename(state_dir)
+    startup_config["gravity"]["process_manager"] = process_manager_name
+    startup_config["gravity"]["instance_name"] = instance_name
     galaxy_yml.write(json.dumps(startup_config))
     runner = CliRunner()
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'register', str(galaxy_yml)])
-    assert result.exit_code == 0, result.output
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'update'])
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'update'])
     assert result.exit_code == 0, result.output
-    start_instance(state_dir, free_port)
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'status'])
-    celery_beat_db_paths = map(lambda f: state_dir / f, CELERY_BEAT_DB_FILENAMES)
+    start_instance(state_dir, galaxy_yml, free_port, process_manager_name, instance_name=instance_name)
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'status'])
+    celery_beat_db_paths = list(map(lambda f: state_dir / f, CELERY_BEAT_DB_FILENAMES))
     celery_beat_db_exists = wait_for_any_path(celery_beat_db_paths, CELERY_BEAT_TIMEOUT)
     assert celery_beat_db_exists is True, "celery-beat failed to write db. State dir contents:\n" \
         f"{os.listdir(state_dir)}"
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'stop'])
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'stop'])
+    assert result.exit_code == 0, result.output
+    if process_manager_name == "supervisor":
+        assert "All processes stopped, supervisord will exit" in result.output
+    else:
+        assert "" == result.output
+
+
+def test_cmd_start_reports(state_dir, galaxy_yml, reports_config, free_port):
+    galaxy_yml.write(json.dumps(reports_config))
+    runner = CliRunner()
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'update'])
+    assert result.exit_code == 0, result.output
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'start'])
+    assert re.search(r"reports\s*STARTING", result.output)
+    assert result.exit_code == 0, result.output
+    startup_done, startup_logs = wait_for_startup(state_dir, free_port, path="/", service_name="reports")
+    assert startup_done is True, f"Startup failed. Application startup logs:\n {startup_logs}"
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'stop'])
     assert result.exit_code == 0, result.output
     assert "All processes stopped, supervisord will exit" in result.output
 
 
-@pytest.mark.parametrize('process_manager_name', ['supervisor'])
+@pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_cmd_start_with_gxit(state_dir, galaxy_yml, gxit_startup_config, free_port, process_manager_name):
+    instance_name = gxit_startup_config["gravity"]["instance_name"]
     galaxy_yml.write(json.dumps(gxit_startup_config))
     runner = CliRunner()
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'register', str(galaxy_yml)])
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'update'])
+    assert result.exit_code == 0, result.output
+    start_time = time.time()
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'start'])
     assert result.exit_code == 0, result.output
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'update'])
+    start_instance(state_dir, galaxy_yml, free_port, process_manager_name, instance_name=instance_name)
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'status'])
     assert result.exit_code == 0, result.output
-    start_instance(state_dir, free_port)
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'status'])
-    assert result.exit_code == 0, f"{result.output}\ngx-it-proxy startup failed. " \
-        f"gx-it-proxy startup logs:\n{open(state_dir / 'log' / 'gx-it-proxy.log').read()}"
-    startup_done = wait_for_gxit_proxy(state_dir)
-    assert startup_done is True, f"gx-it-proxy startup failed. gx-it-proxy startup logs:\n {startup_done}"
+    startup_done, startup_logs = wait_for_gxit_proxy(state_dir, process_manager_name, start_time)
+    assert startup_done is True, f"gx-it-proxy startup failed. gx-it-proxy startup logs:\n {startup_logs}"
 
 
 def test_cmd_restart_with_update(state_dir, galaxy_yml, startup_config, free_port):
     galaxy_yml.write(json.dumps(startup_config))
     runner = CliRunner()
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'register', str(galaxy_yml)])
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'update'])
     assert result.exit_code == 0, result.output
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'update'])
-    assert result.exit_code == 0, result.output
-    start_instance(state_dir, free_port)
+    start_instance(state_dir, galaxy_yml, free_port)
     # change prefix
     prefix = '/galaxypf/'
     startup_config['galaxy']['galaxy_url_prefix'] = prefix
     galaxy_yml.write(json.dumps(startup_config))
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'restart'])
-    assert result.exit_code == 0, result.output
-    startup_done = wait_for_startup(state_dir=state_dir, free_port=free_port, prefix=prefix)
-    assert startup_done is True, f"Startup failed. Application startup logs:\n {startup_done}"
-
-
-def test_cmd_update_with_0_x_config(state_dir, configstate_yaml_0_x):
-    runner = CliRunner()
-    configstate_yaml = state_dir / "configstate.yaml"
-    open(configstate_yaml, "w").write(configstate_yaml_0_x)
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'update'])
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'restart'])
     assert result.exit_code == 0, result.output
-    assert "Converting Gravity config state to 1.0 format" in result.output
-    assert "Adding service gunicorn" in result.output
-    assert "Adding service celery" in result.output
-    assert "Adding service celery-beat" in result.output
+    startup_done, startup_logs = wait_for_startup(state_dir=state_dir, free_port=free_port, prefix=prefix)
+    assert startup_done is True, f"Startup failed. Application startup logs:\n {startup_logs}"
 
 
 def test_cmd_show(state_dir, galaxy_yml):
-    test_cmd_register(state_dir, galaxy_yml)
     runner = CliRunner()
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'show', str(galaxy_yml)])
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'show'])
     assert result.exit_code == 0, result.output
     details = safe_load(result.output)
     assert details['config_type'] == 'galaxy'
 
 
-def test_cmd_show_config_does_not_exist(state_dir, galaxy_yml):
+def test_cmd_list(state_dir, galaxy_yml):
     runner = CliRunner()
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'show', str(galaxy_yml)])
-    assert result.exit_code == 1
-    assert f"{str(galaxy_yml)} is not a registered config file." in result.output
-    assert "No config files have been registered." in result.output
-    assert "Registered config files are:" not in result.output
-    assert f'To register this config file run "galaxyctl register {str(galaxy_yml)}"' in result.output
-    # register the sample file, but ask for galaxy.yml
-    sample_file = Path(galaxy_yml).parent / 'galaxy.yml.sample'
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'register', str(sample_file)])
-    assert result.exit_code == 0, result.output
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'show', str(galaxy_yml)])
-    assert result.exit_code == 1
-    assert f"{str(galaxy_yml)} is not a registered config file." in result.output
-    assert "Registered config files are:" in result.output
-    assert f'To register this config file run "galaxyctl register {str(galaxy_yml)}"'
-
-
-def test_cmd_instances(state_dir, galaxy_yml):
-    runner = CliRunner()
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'instances'])
-    assert result.exit_code == 0, result.output
-    assert not result.output
-    test_cmd_register(state_dir, galaxy_yml)
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'instances'])
-    assert result.exit_code == 0, result.output
-    assert "_default_" in result.output
-
-
-def test_cmd_configs(state_dir, galaxy_yml):
-    runner = CliRunner()
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'configs'])
-    assert result.exit_code == 0, result.output
-    assert 'No config files registered' in result.output
-    test_cmd_register(state_dir, galaxy_yml)
-    result = runner.invoke(galaxyctl, ['--state-dir', state_dir, 'configs'])
+    result = runner.invoke(galaxyctl, ['--config-file', str(galaxy_yml), 'list'])
     assert result.exit_code == 0, result.output
     assert result.output.startswith("TYPE")
     assert str(galaxy_yml) in result.output
diff --git a/tests/test_process_manager.py b/tests/test_process_manager.py
index 5442207..ed7a528 100644
--- a/tests/test_process_manager.py
+++ b/tests/test_process_manager.py
@@ -1,5 +1,6 @@
 import json
 import os
+import time
 import string
 from pathlib import Path
 
@@ -61,6 +62,7 @@
 gravity:
   process_manager: %(process_manager_name)s
   service_command_style: direct
+  instance_name: %(instance_name)s
   handlers:
     handler:
       processes: 2
@@ -92,48 +94,53 @@
 
 
 def service_conf_dir(state_dir, process_manager_name):
+    instance_name = os.path.basename(state_dir)
     state_dir = Path(state_dir)
     if process_manager_name == 'supervisor':
-        return state_dir / 'supervisor' / 'supervisord.conf.d' / '_default_.d'
+        return state_dir / 'supervisor' / 'supervisord.conf.d' / f'{instance_name}.d'
     elif process_manager_name == 'systemd':
         return Path(os.environ.get('GRAVITY_SYSTEMD_UNIT_PATH'))
     raise Exception(f"Invalid process manager name: {process_manager_name}")
 
 
-def service_conf_file(process_manager_name, service_name, service_type=None):
+def service_conf_file(instance_name, process_manager_name, service_name, service_type=None):
     service_type = service_type or service_name
     if process_manager_name == 'supervisor':
         return f'galaxy_{service_type}_{service_name}.conf'
     elif process_manager_name == 'systemd':
-        return f'galaxy-{service_name}.service'
+        return f'galaxy-{instance_name}-{service_name}.service'
     raise Exception(f"Invalid process manager name: {process_manager_name}")
 
 
 def service_conf_path(state_dir, process_manager_name, service_name, service_type=None):
+    instance_name = os.path.basename(state_dir)
     conf_dir = service_conf_dir(state_dir, process_manager_name)
-    conf_file = service_conf_file(process_manager_name, service_name, service_type)
+    conf_file = service_conf_file(instance_name, process_manager_name, service_name, service_type)
     return conf_dir / conf_file
 
 
 @pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_update(galaxy_yml, default_config_manager, process_manager_name):
-    default_config_manager.add([str(galaxy_yml)])
     new_bind = 'localhost:8081'
+    instance_name = os.path.basename(default_config_manager.state_dir)
     galaxy_yml.write(json.dumps(
-        {'galaxy': None, 'gravity': {'process_manager': process_manager_name, 'gunicorn': {'bind': new_bind}}}))
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+        {'galaxy': None, 'gravity': {'process_manager': process_manager_name,
+                                     'service_command_style': 'direct',
+                                     'instance_name': instance_name,
+                                     'gunicorn': {'bind': new_bind}}}))
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
 
 
 @pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_update_default_services(galaxy_yml, default_config_manager, process_manager_name):
     test_update(galaxy_yml, default_config_manager, process_manager_name)
-    conf_dir = service_conf_dir(default_config_manager.state_dir, process_manager_name)
     gunicorn_conf_path = service_conf_path(default_config_manager.state_dir, process_manager_name, 'gunicorn')
     assert gunicorn_conf_path.exists()
-    celery_conf_path = conf_dir / service_conf_file(process_manager_name, 'celery')
+    celery_conf_path = service_conf_path(default_config_manager.state_dir, process_manager_name, 'celery')
     assert celery_conf_path.exists()
-    celery_beat_conf_path = conf_dir / service_conf_file(process_manager_name, 'celery-beat')
+    celery_beat_conf_path = service_conf_path(default_config_manager.state_dir, process_manager_name, 'celery-beat')
     assert celery_beat_conf_path.exists()
 
 
@@ -143,10 +150,13 @@ def test_update_force(galaxy_yml, default_config_manager, process_manager_name):
     gunicorn_conf_path = service_conf_path(default_config_manager.state_dir, process_manager_name, 'gunicorn')
     assert gunicorn_conf_path.exists()
     update_time = gunicorn_conf_path.stat().st_mtime
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
+    # the linux kernel time cache is only updated on timer interrupts, in my testing that's around 4ms but can be larger
+    # depending on many factors, so introduce a healthy delay to ensure the rewrite happens after the next interrupt
+    time.sleep(0.1)
     assert gunicorn_conf_path.stat().st_mtime == update_time
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update(force=True)
     assert gunicorn_conf_path.stat().st_mtime != update_time
 
@@ -157,12 +167,11 @@ def test_cleanup(galaxy_yml, default_config_manager, process_manager_name):
     gunicorn_conf_path = service_conf_path(default_config_manager.state_dir, process_manager_name, 'gunicorn')
     celery_conf_path = service_conf_path(default_config_manager.state_dir, process_manager_name, 'celery')
     celery_beat_conf_path = service_conf_path(default_config_manager.state_dir, process_manager_name, 'celery-beat')
-    default_config_manager.remove([str(galaxy_yml)])
     assert gunicorn_conf_path.exists()
     assert celery_conf_path.exists()
     assert celery_beat_conf_path.exists()
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
-        pm.update()
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
+        pm.update(clean=True)
     assert not gunicorn_conf_path.exists()
     assert not celery_conf_path.exists()
     assert not celery_beat_conf_path.exists()
@@ -170,34 +179,39 @@ def test_cleanup(galaxy_yml, default_config_manager, process_manager_name):
 
 @pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_disable_services(galaxy_yml, default_config_manager, process_manager_name):
-    default_config_manager.add([str(galaxy_yml)])
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
     galaxy_yml.write(json.dumps(
         {'galaxy': None, 'gravity': {
             'process_manager': process_manager_name,
+            'instance_name': instance_name,
             'gunicorn': {'enable': False},
             'celery': {'enable': False, 'enable_beat': False}}}
     ))
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-    conf_dir = service_conf_dir(default_config_manager.state_dir, process_manager_name)
-    gunicorn_conf_path = conf_dir / service_conf_file(process_manager_name, 'gunicorn')
+    gunicorn_conf_path = service_conf_path(state_dir, process_manager_name, 'gunicorn')
     assert not gunicorn_conf_path.exists()
-    celery_conf_path = conf_dir / service_conf_file(process_manager_name, 'celery')
+    celery_conf_path = service_conf_path(state_dir, process_manager_name, 'celery')
     assert not celery_conf_path.exists()
-    celery_beat_conf_path = conf_dir / service_conf_file(process_manager_name, 'celery-beat')
+    celery_beat_conf_path = service_conf_path(state_dir, process_manager_name, 'celery-beat')
     assert not celery_beat_conf_path.exists()
 
 
 @pytest.mark.parametrize('job_conf', [params["JOB_CONF_XML_DYNAMIC_HANDLERS"]], indirect=True)
 @pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_dynamic_handlers(default_config_manager, galaxy_yml, job_conf, process_manager_name):
-    galaxy_yml.write(DYNAMIC_HANDLER_CONFIG % {"process_manager_name": process_manager_name})
-    default_config_manager.add([str(galaxy_yml)])
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
+    galaxy_yml.write(DYNAMIC_HANDLER_CONFIG % {"process_manager_name": process_manager_name,
+                                               "instance_name": instance_name})
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-        conf_dir = service_conf_dir(default_config_manager.state_dir, process_manager_name)
+        conf_dir = service_conf_dir(state_dir, process_manager_name)
         handler_config_paths = [conf_dir / service_conf_file(
-            process_manager_name, f'handler{i}', service_type='standalone') for i in range(3)]
+            instance_name, process_manager_name, f'handler{i}', service_type='standalone') for i in range(3)]
         for config_path in handler_config_paths:
             assert config_path.exists()
         handler0_config = handler_config_paths[0].open().read()
@@ -217,16 +231,16 @@ def test_dynamic_handlers(default_config_manager, galaxy_yml, job_conf, process_
     'job_conf', [params["JOB_CONF_YAML_NO_HANDLERS"], params["JOB_CONF_XML_NO_HANDLERS"]], indirect=True)
 @pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_no_static_handlers(default_config_manager, galaxy_yml, job_conf, process_manager_name):
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
     with open(galaxy_yml, 'w') as config_fh:
         config_fh.write(json.dumps({
-            'gravity': {'process_manager': process_manager_name},
+            'gravity': {'process_manager': process_manager_name, 'instance_name': instance_name},
             'galaxy': {'job_config_file': str(job_conf)}}))
-    default_config_manager.add([str(galaxy_yml)])
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-        handler_config_path = service_conf_path(
-            default_config_manager.state_dir,
-            process_manager_name, 'handler0', service_type='standalone')
+        handler_config_path = service_conf_path(state_dir, process_manager_name, 'handler0', service_type='standalone')
         assert not handler_config_path.exists()
 
 
@@ -234,75 +248,81 @@ def test_no_static_handlers(default_config_manager, galaxy_yml, job_conf, proces
     'job_conf', [params["JOB_CONF_YAML_STATIC_HANDLERS"], params["JOB_CONF_XML_STATIC_HANDLERS"]], indirect=True)
 @pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_static_handlers(default_config_manager, galaxy_yml, job_conf, process_manager_name):
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
     with open(galaxy_yml, 'w') as config_fh:
         config_fh.write(json.dumps({
-            'gravity': {'process_manager': process_manager_name},
+            'gravity': {'process_manager': process_manager_name, 'instance_name': instance_name},
             'galaxy': {'job_config_file': str(job_conf)}}))
-    default_config_manager.add([str(galaxy_yml)])
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-        conf_dir = service_conf_dir(default_config_manager.state_dir, process_manager_name)
-        handler0_config_path = conf_dir / service_conf_file(process_manager_name, 'handler0', service_type='standalone')
+        handler0_config_path = service_conf_path(state_dir, process_manager_name, 'handler0', service_type='standalone')
         assert handler0_config_path.exists()
-        assert 'exec _default_ handler0' in handler0_config_path.open().read()
-        handler1_config_path = conf_dir / service_conf_file(process_manager_name, 'handler1', service_type='standalone')
+        assert f'exec {instance_name} handler0' in handler0_config_path.open().read()
+        handler1_config_path = service_conf_path(state_dir, process_manager_name, 'handler1', service_type='standalone')
         assert handler1_config_path.exists()
         handler1_config = handler1_config_path.open().read()
-        assert 'exec _default_ handler1' in handler1_config
+        assert f'exec {instance_name} handler1' in handler1_config
         for handler_name in ('sge_handler', 'special_handler0', 'special_handler1'):
-            assert (conf_dir / service_conf_file(process_manager_name, handler_name, service_type='standalone')).exists()
+            assert service_conf_path(state_dir, process_manager_name, handler_name, service_type='standalone').exists()
 
 
 @pytest.mark.parametrize('job_conf', [params["JOB_CONF_YAML_STATIC_HANDLERS"]], indirect=True)
 @pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_static_handlers_direct(default_config_manager, galaxy_yml, job_conf, process_manager_name):
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
     with open(galaxy_yml, 'w') as config_fh:
         config_fh.write(json.dumps({
-            'gravity': {'process_manager': process_manager_name, 'service_command_style': 'direct'},
+            'gravity': {'process_manager': process_manager_name,
+                        'service_command_style': 'direct',
+                        'instance_name': instance_name},
             'galaxy': {'job_config_file': str(job_conf)}}))
-    default_config_manager.add([str(galaxy_yml)])
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-        conf_dir = service_conf_dir(default_config_manager.state_dir, process_manager_name)
-        handler0_config_path = conf_dir / service_conf_file(process_manager_name, 'handler0', service_type='standalone')
+        handler0_config_path = service_conf_path(state_dir, process_manager_name, 'handler0', service_type='standalone')
         assert handler0_config_path.exists()
         assert '.yml --server-name=handler0' in handler0_config_path.open().read()
-        handler1_config_path = conf_dir / service_conf_file(process_manager_name, 'handler1', service_type='standalone')
+        handler1_config_path = service_conf_path(state_dir, process_manager_name, 'handler1', service_type='standalone')
         assert handler1_config_path.exists()
         handler1_config = handler1_config_path.open().read()
         assert '.yml --server-name=handler1' in handler1_config
         assert 'BAZ=baz' in handler1_config
         for handler_name in ('sge_handler', 'special_handler0', 'special_handler1'):
-            assert (conf_dir / service_conf_file(process_manager_name, handler_name, service_type='standalone')).exists()
+            assert service_conf_path(state_dir, process_manager_name, handler_name, service_type='standalone').exists()
 
 
 @pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_static_handlers_embedded_in_galaxy_yml(default_config_manager, galaxy_yml, process_manager_name):
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
     with open(galaxy_yml, 'w') as config_fh:
         config_fh.write(json.dumps({
-            'gravity': {'process_manager': process_manager_name},
+            'gravity': {'process_manager': process_manager_name, 'instance_name': instance_name},
             'galaxy': {'job_config': safe_load(JOB_CONF_YAML_STATIC_HANDLERS)}}))
-    default_config_manager.add([str(galaxy_yml)])
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-        conf_dir = service_conf_dir(default_config_manager.state_dir, process_manager_name)
-        handler0_config_path = conf_dir / service_conf_file(process_manager_name, 'handler0', service_type='standalone')
+        handler0_config_path = service_conf_path(state_dir, process_manager_name, 'handler0', service_type='standalone')
         assert handler0_config_path.exists()
-        assert 'exec _default_ handler0' in handler0_config_path.open().read()
-        handler1_config_path = conf_dir / service_conf_file(process_manager_name, 'handler1', service_type='standalone')
+        assert f'exec {instance_name} handler0' in handler0_config_path.open().read()
+        handler1_config_path = service_conf_path(state_dir, process_manager_name, 'handler1', service_type='standalone')
         assert handler1_config_path.exists()
-        assert 'exec _default_ handler1' in handler1_config_path.open().read()
+        assert f'exec {instance_name} handler1' in handler1_config_path.open().read()
         for handler_name in ('sge_handler', 'special_handler0', 'special_handler1'):
-            assert (conf_dir / service_conf_file(process_manager_name, handler_name, service_type='standalone')).exists()
+            assert service_conf_path(state_dir, process_manager_name, handler_name, service_type='standalone').exists()
 
 
 @pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_gxit_handler(default_config_manager, galaxy_yml, gxit_config, process_manager_name):
+    state_dir = default_config_manager.state_dir
     galaxy_yml.write(json.dumps(gxit_config))
-    default_config_manager.add([str(galaxy_yml)])
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-        gxit_config_path = service_conf_path(default_config_manager.state_dir, process_manager_name, 'gx-it-proxy')
+        gxit_config_path = service_conf_path(state_dir, process_manager_name, 'gx-it-proxy')
         assert gxit_config_path.exists()
         gxit_port = gxit_config["gravity"]["gx_it_proxy"]["port"]
         sessions = "database/interactivetools_map.sqlite"
@@ -311,68 +331,141 @@ def test_gxit_handler(default_config_manager, galaxy_yml, gxit_config, process_m
 
 @pytest.mark.parametrize('process_manager_name', ['supervisor', 'systemd'])
 def test_tusd_process(default_config_manager, galaxy_yml, tusd_config, process_manager_name):
+    state_dir = default_config_manager.state_dir
     galaxy_yml.write(json.dumps(tusd_config))
-    default_config_manager.add([str(galaxy_yml)])
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-        tusd_config_path = service_conf_path(default_config_manager.state_dir, process_manager_name, 'tusd')
+        tusd_config_path = service_conf_path(state_dir, process_manager_name, 'tusd')
         assert tusd_config_path.exists()
         assert "tusd -host" in tusd_config_path.read_text()
 
 
 def test_default_memory_limit(galaxy_yml, default_config_manager):
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
     process_manager_name = 'systemd'
     galaxy_yml.write(json.dumps(
         {'galaxy': None, 'gravity': {
             'process_manager': process_manager_name,
+            'instance_name': instance_name,
             'memory_limit': 2,
             'handlers': {'handler': {}}}}))
-    default_config_manager.add([str(galaxy_yml)])
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-    conf_dir = service_conf_dir(default_config_manager.state_dir, process_manager_name)
-    gunicorn_conf_path = conf_dir / service_conf_file(process_manager_name, 'gunicorn')
+    conf_dir = service_conf_dir(state_dir, process_manager_name)
+    gunicorn_conf_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'gunicorn')
     assert 'MemoryLimit=2G' in gunicorn_conf_path.open().read()
-    handler0_config_path = conf_dir / service_conf_file(process_manager_name, 'handler_0', service_type='standalone')
+    handler0_config_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'handler_0', service_type='standalone')
     assert handler0_config_path.exists(), os.listdir(conf_dir)
     assert 'MemoryLimit=2G' in handler0_config_path.open().read()
 
 
 def test_service_memory_limit(galaxy_yml, default_config_manager):
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
     process_manager_name = 'systemd'
     galaxy_yml.write(json.dumps(
         {'galaxy': None, 'gravity': {
             'process_manager': process_manager_name,
+            'instance_name': instance_name,
             'gunicorn': {'memory_limit': 4},
             'handlers': {'handler': {}}}}))
-    default_config_manager.add([str(galaxy_yml)])
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-    conf_dir = service_conf_dir(default_config_manager.state_dir, process_manager_name)
-    gunicorn_conf_path = conf_dir / service_conf_file(process_manager_name, 'gunicorn')
+    conf_dir = service_conf_dir(state_dir, process_manager_name)
+    gunicorn_conf_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'gunicorn')
     assert 'MemoryLimit=4G' in gunicorn_conf_path.open().read()
-    handler0_config_path = conf_dir / service_conf_file(process_manager_name, 'handler_0', service_type='standalone')
+    handler0_config_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'handler_0', service_type='standalone')
     assert handler0_config_path.exists(), os.listdir(conf_dir)
     assert 'MemoryLimit' not in handler0_config_path.open().read()
 
 
 def test_override_memory_limit(galaxy_yml, default_config_manager):
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
     process_manager_name = 'systemd'
     galaxy_yml.write(json.dumps(
         {'galaxy': None, 'gravity': {
             'process_manager': process_manager_name,
+            'instance_name': instance_name,
             'memory_limit': 2,
             'gunicorn': {'memory_limit': 4},
             'handlers': {'handler': {}}}}))
-    default_config_manager.add([str(galaxy_yml)])
-    with process_manager.process_manager(state_dir=default_config_manager.state_dir) as pm:
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
         pm.update()
-    conf_dir = service_conf_dir(default_config_manager.state_dir, process_manager_name)
-    gunicorn_conf_path = conf_dir / service_conf_file(process_manager_name, 'gunicorn')
+    conf_dir = service_conf_dir(state_dir, process_manager_name)
+    gunicorn_conf_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'gunicorn')
     assert 'MemoryLimit=4G' in gunicorn_conf_path.open().read()
-    handler0_config_path = conf_dir / service_conf_file(process_manager_name, 'handler_0', service_type='standalone')
+    handler0_config_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'handler_0', service_type='standalone')
     assert handler0_config_path.exists(), os.listdir(conf_dir)
     assert 'MemoryLimit=2G' in handler0_config_path.open().read()
 
 
+def test_default_umask(galaxy_yml, default_config_manager):
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
+    process_manager_name = 'systemd'
+    galaxy_yml.write(json.dumps(
+        {'galaxy': None, 'gravity': {
+            'process_manager': process_manager_name,
+            'instance_name': instance_name,
+            'handlers': {'handler': {}}}}))
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
+        pm.update()
+    conf_dir = service_conf_dir(state_dir, process_manager_name)
+    gunicorn_conf_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'gunicorn')
+    assert 'UMask=022' in gunicorn_conf_path.open().read()
+    handler0_config_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'handler_0', service_type='standalone')
+    assert handler0_config_path.exists(), os.listdir(conf_dir)
+    assert 'UMask=022' in handler0_config_path.open().read()
+
+
+def test_service_umask(galaxy_yml, default_config_manager):
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
+    process_manager_name = 'systemd'
+    galaxy_yml.write(json.dumps(
+        {'galaxy': None, 'gravity': {
+            'process_manager': process_manager_name,
+            'instance_name': instance_name,
+            'gunicorn': {'umask': "077"},
+            'handlers': {'handler': {}}}}))
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
+        pm.update()
+    conf_dir = service_conf_dir(state_dir, process_manager_name)
+    gunicorn_conf_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'gunicorn')
+    assert 'UMask=077' in gunicorn_conf_path.open().read()
+    handler0_config_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'handler_0', service_type='standalone')
+    assert handler0_config_path.exists(), os.listdir(conf_dir)
+    assert 'UMask=022' in handler0_config_path.open().read()
+
+
+def test_override_umask(galaxy_yml, default_config_manager):
+    state_dir = default_config_manager.state_dir
+    instance_name = os.path.basename(state_dir)
+    process_manager_name = 'systemd'
+    galaxy_yml.write(json.dumps(
+        {'galaxy': None, 'gravity': {
+            'process_manager': process_manager_name,
+            'instance_name': instance_name,
+            'umask': "027",
+            'gunicorn': {'umask': "077"},
+            'handlers': {'handler': {}}}}))
+    default_config_manager.load_config_file(str(galaxy_yml))
+    with process_manager.process_manager(config_manager=default_config_manager) as pm:
+        pm.update()
+    conf_dir = service_conf_dir(state_dir, process_manager_name)
+    gunicorn_conf_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'gunicorn')
+    assert 'UMask=077' in gunicorn_conf_path.open().read()
+    handler0_config_path = conf_dir / service_conf_file(instance_name, process_manager_name, 'handler_0', service_type='standalone')
+    assert handler0_config_path.exists(), os.listdir(conf_dir)
+    assert 'UMask=027' in handler0_config_path.open().read()
+
+
 # TODO: test switching PMs in between invocations, test multiple instances