diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,9 @@
+# Revision history for Arion
+
+## Unreleased
+
+<!-- TODO: use better template -->
+
+## 0.1.0.0 -- 2019-10-04
+
+* First released version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2019 Hercules Labs OÜ
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/README.asciidoc b/README.asciidoc
new file mode 100644
--- /dev/null
+++ b/README.asciidoc
@@ -0,0 +1,274 @@
+== Introduction
+
+Arion is a tool for building and running applications that
+consist of multiple docker containers using NixOS modules.
+It has special support for docker images that are built with Nix,
+for a smooth development experience and improved performance.
+
+It is built on top of https://docs.docker.com/compose/overview/[Docker
+Compose], which implements the container orchestration functionality.
+
+Instead of configuring the compositions in YAML files like
+`docker-compose.yaml`, Arion uses the https://nixos.org/nix/[Nix]
+language to declare the compositions. Because of this, Arion gives you
+the ability to declare your deployments, configuration and packaging
+in the same language. By replacing multiple tools with a single
+language, you decrease your mental load and you can more easily
+refactor and maintain your configurations.
+
+Although Arion can be used as a Docker Compose with an improved
+configuration front end, there is more to be gained from integrating
+with Nix. In particular, the more structured approach of Nix compared
+to Dockerfiles allows the following:
+
+ * Build components of your image in *parallel, automatically*
+ * *Share packages between images*, regardless of the order they were
+   added
+ * Improve performance by *skipping container
+   image creation*
+ * Work with *structured data instead of strings*,
+   templates and a multitude of expression languages
+ * Refactor across deployments, configuration and packaging
+
+Arion allows to compose containers with different granularity:
+
+  * <<Minimal: Plain command using nixpkgs>>
+  * <<NixOS: run only one systemd service>>
+  * <<NixOS: run full OS>>
+  * <<Docker image from DockerHub>>
+
+== Installation
+
+=== Nix
+
+```bash
+$ nix-env -iA arion -f https://github.com/hercules-ci/arion/tarball/master
+```
+
+=== NixOS
+
+Add this module to your NixOS configuration:
+
+```nix
+{ ... }: {
+  environment.systemPackages = [ (import (builtins.fetchTarball https://github.com/hercules-ci/arion/tarball/master) {}).arion ];
+  virtualisation.docker.enable = true;
+  users.extraUsers.myuser.extraGroups = ["docker"];
+}
+```
+
+////
+
+== Not installing: use it in a project
+
+TODO: describe: using nix-shell or in a script, building images as
+      part of nix-build, pinning, see also todomvc-nix.
+
+TODO: exposed Nix functions: arion.build, arion.eval (a bit of IFD)
+
+
+////
+
+
+== Usage
+
+Arion is configured declaratively with two files:
+
+=== arion-pkgs.nix
+
+Arion needs `arion-pkgs.nix` to import nixpkgs, it's contents can be as simple as:
+
+```nix
+import <nixpkgs> {}
+```
+
+or more sophisticated (recommended) setup with https://github.com/nmattia/niv[Niv].
+
+=== arion-compose.nix
+
+Describe containers using NixOS-style modules. There are a few options:
+
+==== Minimal: Plain command using nixpkgs
+
+`examples/minimal/arion-compose.nix`:
+
+```nix
+{ pkgs, ... }:
+{
+  config.docker-compose.services = {
+
+    webserver = {
+      service.useHostStore = true;
+      service.command = [ "sh" "-c" ''
+                  cd "$$WEB_ROOT"
+                  ${pkgs.python3}/bin/python -m http.server
+                '' ];
+      service.ports = [
+        "8000:8000" # host:container
+      ];
+      service.environment.WEB_ROOT = "${pkgs.nix.doc}/share/doc/nix/manual";
+    };
+  };
+}
+
+
+```
+
+==== NixOS: run only one systemd service
+
+`examples/nixos-unit/arion-compose.nix`:
+
+```nix
+
+{
+  docker-compose.services.webserver = { config, pkgs, ... }: {
+
+    nixos.configuration = {config, pkgs, ...}: {
+      boot.isContainer = true;
+      services.nginx.enable = true;
+      services.nginx.virtualHosts.localhost.root = "${pkgs.nix.doc}/share/doc/nix/manual";
+      system.build.run-nginx = pkgs.writeScript "run-nginx" ''
+        #!${pkgs.bash}/bin/bash
+        PATH='${config.systemd.services.nginx.environment.PATH}'
+        echo nginx:x:${toString config.users.users.nginx.uid}:${toString config.users.groups.nginx.gid}:nginx web server user:/var/empty:/bin/sh >>/etc/passwd
+        echo nginx:x:${toString config.users.groups.nginx.gid}:nginx >>/etc/group
+        ${config.systemd.services.nginx.runner}
+      '';
+    };
+    service.command = [ config.nixos.build.run-nginx ];
+    service.useHostStore = true;
+    service.ports = [
+      "8000:80" # host:container
+    ];
+  };
+}
+
+```
+
+==== NixOS: run full OS
+
+`examples/full-nixos/arion-compose.nix`:
+
+```nix
+{
+  docker-compose.services.webserver = { pkgs, ... }: {
+    nixos.useSystemd = true;
+    nixos.configuration.boot.tmpOnTmpfs = true;
+    nixos.configuration.services.nginx.enable = true;
+    nixos.configuration.services.nginx.virtualHosts.localhost.root = "${pkgs.nix.doc}/share/doc/nix/manual";
+    service.useHostStore = true;
+    service.ports = [
+      "8000:80" # host:container
+    ];
+  };
+}
+```
+
+==== Docker image from DockerHub
+
+```nix
+{
+  docker-compose.services.postgres = {
+    service.image = "postgres:10";
+    service.volumes = [ "${toString ./.}/postgres-data:/var/lib/postgresql/data" ];
+    service.environment.POSTGRES_PASSWORD = "mydefaultpass";
+  };
+}
+```
+
+=== Run
+
+Start containers and watch their logs:
+
+```bash
+$ arion up -d
+$ arion logs -f
+```
+
+You can go to `examples/*/` and run these commands to give it a quick try.
+
+== A full featured Nix command example
+
+To see how Arion can be used in a project, have a look at
+https://github.com/nix-community/todomvc-nix/tree/master/deploy/arion[todomvc-nix].
+
+```bash
+$ git clone https://github.com/nix-community/todomvc-nix
+$ cd todomvc-nix/deploy/arion
+$ arion up
+```
+
+== Project Status
+
+This project was born out of a process supervision need for local
+development environments while working on
+https://www.hercules-ci.com[Hercules CI]. (It was also born out of
+ancient Greek deities disguised as horses. More on that later.)
+
+If you do want to use Arion for production environments, you’ll probably
+want to either build normal container images or manage garbage
+collection roots if you control the deployment host. Neither scenario is
+made easier by arion at this time.
+
+Arion has run successfully on Linux distributions other than NixOS, but we only perform CI for Arion on NixOS.
+
+
+== How it works
+
+Arion is essentially a thin wrapper around Nix and docker-compose. When
+it runs, it does the following:
+
+* Evaluate the configuration using Nix, producing a
+`docker-compose.yaml` and a garbage collection root
+* Invoke `docker-compose`
+* Clean up the garbage collection root
+
+Most of the interesting stuff happens in Arion’s Nix expressions, where
+it runs the module system (known from NixOS) and provides the
+configuration that makes the Docker Compose file do the things it needs
+to do.
+
+One of the more interesting built-in modules is the
+link:src/nix/modules/service/host-store.nix[host-store.nix module] which
+performs the bind mounts to make the host Nix store available in the
+container.
+
+== FAQ
+
+=== Do I need to use Hercules CI?
+
+Nope, it’s just Nix and Docker Compose under the hood.
+
+=== What about garbage collection?
+
+Arion removes the need for garbage collecting docker images, delegating
+this task to Nix.
+
+Arion creates a garbage collection root and cleans it up after
+completing the command. This means that `arion up` without `-d` is safe
+with respect to garbage collection. A deployment that is more serious
+than local development must leave a GC root on the deployment host. This
+use case is not supported as of now.
+
+=== Why is my container not running latest code?
+
+Restart it with `arion restart <name>` or if you've changed the image rebuild
+them using `arion up -d --always-recreate-deps <name>`.
+
+=== What is messing with my environment variables?
+
+Docker Compose performs its own environment variable substitution. This
+can be a little annoying in `services.command` for example. Either
+reference a script from `pkgs.writeScript` or escape the dollar sign as
+`$$`.
+
+=== Why name it ``Arion``?
+
+Arion comes from Greek mythology. Poseidon, the god of ~Docker~ the seas
+had his eye on Demeter. Demeter tried to trick him by disguising as a
+horse, but Poseidon saw through the deception and they had Arion.
+
+So Arion is a super fast divine horse; the result of some weird mixing.
+Also it talks.
+
+(And we feel morally obliged to name our stuff after Greek mythology)
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/arion-compose.cabal b/arion-compose.cabal
new file mode 100644
--- /dev/null
+++ b/arion-compose.cabal
@@ -0,0 +1,84 @@
+cabal-version:       2.4
+
+name:                arion-compose
+version: 0.1.0.0
+synopsis:            Run docker-compose with help from Nix/NixOS
+description:         Arion is a tool for building and running applications that consist of multiple docker containers using NixOS modules. It has special support for docker images that are built with Nix, for a smooth development experience and improved performance.
+homepage:            https://github.com/hercules-ci/arion#readme
+-- bug-reports:
+license:             Apache-2.0
+license-file:        LICENSE
+author:              Robert Hensing
+maintainer:          robert@hercules-ci.com
+-- copyright:
+category:            Distribution, Network, Cloud, Distributed Computing
+extra-source-files:  CHANGELOG.md, README.asciidoc,
+                     src/haskell/testdata/**/*.nix
+                     src/haskell/testdata/**/*.json
+data-files:          nix/*.nix
+                   , nix/modules/composition/*.nix
+                   , nix/modules/nixos/*.nix
+                   , nix/modules/service/*.nix
+                   , nix/modules/lib/*.nix
+
+-- all data is verbatim from some sources
+data-dir:            src
+
+common common
+  build-depends:     base ^>=4.12.0.0
+                   , aeson
+                   , aeson-pretty
+                   , async
+                   , bytestring
+                   , directory
+                   , lens
+                   , lens-aeson
+                   , process
+                   , temporary
+                   , text
+                   , protolude
+                   , unix
+  ghc-options:     -Wall
+
+flag ghci
+  default: False
+  manual: True
+
+library
+  import:              common
+  exposed-modules:     Arion.Nix
+                       Arion.Aeson
+                       Arion.DockerCompose
+                       Arion.Images
+                       Arion.Services
+  other-modules:       Paths_arion_compose
+  autogen-modules:     Paths_arion_compose
+  -- other-extensions:
+  hs-source-dirs:      src/haskell/lib
+  default-language:    Haskell2010
+
+executable arion
+  import:              common
+  main-is:             Main.hs
+  -- other-modules:
+  -- other-extensions:
+  build-depends:       optparse-applicative
+                     , arion-compose
+  hs-source-dirs:      src/haskell/exe
+  default-language:    Haskell2010
+
+test-suite arion-unit-tests
+  import:              common
+  if flag(ghci)
+    hs-source-dirs:      src/haskell/lib
+    ghc-options:       -Wno-missing-home-modules
+  type:                exitcode-stdio-1.0
+  main-is:             TestMain.hs
+  other-modules:       Spec
+                     , Arion.NixSpec
+  -- other-extensions:
+  build-depends:       arion-compose
+                     , hspec
+                     , QuickCheck
+  hs-source-dirs:      src/haskell/test
+  default-language:    Haskell2010
diff --git a/src/haskell/exe/Main.hs b/src/haskell/exe/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/haskell/exe/Main.hs
@@ -0,0 +1,265 @@
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ApplicativeDo #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+import Protolude hiding (Down, option)
+
+import           Arion.Nix
+import           Arion.Aeson
+import           Arion.Images (loadImages)
+import qualified Arion.DockerCompose as DockerCompose
+import           Arion.Services (getDefaultExec)
+
+import Options.Applicative
+import Control.Monad.Fail
+
+import qualified Data.Text as T
+import qualified Data.Text.IO as T
+
+import Data.List.NonEmpty (NonEmpty(..))
+
+import System.Posix.User (getRealUserID)
+
+data CommonOptions =
+  CommonOptions
+    { files :: NonEmpty FilePath
+    , pkgs :: Text
+    , nixArgs :: [Text]
+    }
+  deriving (Show)
+
+newtype DockerComposeArgs =
+  DockerComposeArgs { unDockerComposeArgs :: [Text] }
+
+ensureConfigFile :: [FilePath] -> NonEmpty FilePath
+ensureConfigFile []     = "./arion-compose.nix" :| []
+ensureConfigFile (x:xs) = x :| xs
+
+parseOptions :: Parser CommonOptions
+parseOptions = do
+    files <-
+      ensureConfigFile <$>
+        many (strOption
+               (  short 'f'
+               <> long "file"
+               <> metavar "FILE"
+               <> help "Use FILE instead of the default ./arion-compose.nix. \
+                        \Can be specified multiple times for a merged configuration" ))
+    pkgs <- T.pack <$> strOption
+          (  short 'p'
+          <> long "pkgs"
+          <> metavar "EXPR"
+          <> showDefault
+          <> value "./arion-pkgs.nix"
+          <> help "Use Nix expression EXPR to get the Nixpkgs attrset used for bootstrapping \
+                   \and evaluating the configuration." )
+    showTrace <- flag False True (long "show-trace"
+                    <> help "Causes Nix to print out a stack trace in case of Nix expression evaluation errors.")
+    -- TODO --option support (https://github.com/pcapriotti/optparse-applicative/issues/284)
+    userNixArgs <- many (T.pack <$> strOption (long "nix-arg" <> metavar "ARG" <> help "Pass an extra argument to nix. Example: --nix-arg --option --nix-arg substitute --nix-arg false"))
+    pure $
+      let nixArgs = userNixArgs <|> "--show-trace" <$ guard showTrace
+      in CommonOptions{..}
+
+textArgument :: Mod ArgumentFields [Char] -> Parser Text
+textArgument = fmap T.pack . strArgument
+
+parseCommand :: Parser (CommonOptions -> IO ())
+parseCommand =
+  hsubparser
+    (    command "cat" (info (pure runCat) (progDesc "Spit out the docker compose file as JSON" <> fullDesc))
+      <> command "repl" (info (pure runRepl) (progDesc "Start a nix repl for the whole composition" <> fullDesc))
+      <> command "exec" (info (parseExecCommand) (progDesc "Execute a command in a running container" <> fullDesc))
+    )
+  <|>
+  hsubparser
+    (    commandDC runBuildAndDC "build" "Build or rebuild services"
+      <> commandDC runBuildAndDC "bundle" "Generate a Docker bundle from the Compose file"
+      <> commandDC runEvalAndDC "config" "Validate and view the Compose file"
+      <> commandDC runBuildAndDC "create" "Create services"
+      <> commandDC runEvalAndDC "down" "Stop and remove containers, networks, images, and volumes"
+      <> commandDC runEvalAndDC "events" "Receive real time events from containers"
+      <> commandDC runDC "help" "Get help on a command"
+      <> commandDC runEvalAndDC "images" "List images"
+      <> commandDC runEvalAndDC "kill" "Kill containers"
+      <> commandDC runEvalAndDC "logs" "View output from containers"
+      <> commandDC runEvalAndDC "pause" "Pause services"
+      <> commandDC runEvalAndDC "port" "Print the public port for a port binding"
+      <> commandDC runEvalAndDC "ps" "List containers"
+      <> commandDC runBuildAndDC "pull" "Pull service images"
+      <> commandDC runBuildAndDC "push" "Push service images"
+      <> commandDC runBuildAndDC "restart" "Restart services"
+      <> commandDC runEvalAndDC "rm" "Remove stopped containers"
+      <> commandDC runBuildAndDC "run" "Run a one-off command"
+      <> commandDC runBuildAndDC "scale" "Set number of containers for a service"
+      <> commandDC runBuildAndDC "start" "Start services"
+      <> commandDC runEvalAndDC "stop" "Stop services"
+      <> commandDC runEvalAndDC "top" "Display the running processes"
+      <> commandDC runEvalAndDC "unpause" "Unpause services"
+      <> commandDC runBuildAndDC "up" "Create and start containers"
+      <> commandDC runDC "version" "Show the Docker-Compose version information"
+
+      <> metavar "DOCKER-COMPOSE-COMMAND"
+      <> commandGroup "Docker Compose Commands:"
+    )
+
+parseAll :: Parser (IO ())
+parseAll =
+  flip ($) <$> parseOptions <*> parseCommand
+
+parseDockerComposeArgs :: Parser DockerComposeArgs
+parseDockerComposeArgs =
+  DockerComposeArgs <$>
+    many (argument (T.pack <$> str) (metavar "DOCKER-COMPOSE ARGS..."))
+
+commandDC
+  :: (Text -> DockerComposeArgs -> CommonOptions -> IO ())
+  -> Text
+  -> Text
+  -> Mod CommandFields (CommonOptions -> IO ())
+commandDC run cmdStr helpText =
+  command
+    (T.unpack cmdStr)
+    (info
+      (run cmdStr <$> parseDockerComposeArgs)
+      (progDesc (T.unpack helpText) <> fullDesc <> forwardOptions))
+
+
+--------------------------------------------------------------------------------
+
+runDC :: Text -> DockerComposeArgs -> CommonOptions -> IO ()
+runDC cmd (DockerComposeArgs args) _opts = do
+  DockerCompose.run DockerCompose.Args
+    { files = []
+    , otherArgs = [cmd] ++ args
+    }
+
+runBuildAndDC :: Text -> DockerComposeArgs -> CommonOptions -> IO ()
+runBuildAndDC cmd dopts opts = do
+  ea <- defaultEvaluationArgs opts
+  Arion.Nix.withBuiltComposition ea $ \path -> do
+    loadImages path
+    DockerCompose.run DockerCompose.Args
+      { files = [path]
+      , otherArgs = [cmd] ++ unDockerComposeArgs dopts
+      }
+
+runEvalAndDC :: Text -> DockerComposeArgs -> CommonOptions -> IO ()
+runEvalAndDC cmd dopts opts = do
+  ea <- defaultEvaluationArgs opts
+  Arion.Nix.withEvaluatedComposition ea $ \path ->
+    DockerCompose.run DockerCompose.Args
+      { files = [path]
+      , otherArgs = [cmd] ++ unDockerComposeArgs dopts
+      }
+
+defaultEvaluationArgs :: CommonOptions -> IO EvaluationArgs
+defaultEvaluationArgs co = do
+  uid <- getRealUserID
+  pure EvaluationArgs
+    { evalUid = fromIntegral uid
+    , evalModules = files co
+    , evalPkgs = pkgs co
+    , evalWorkDir = Nothing
+    , evalMode = ReadWrite
+    , evalUserArgs = nixArgs co
+    }
+
+runCat :: CommonOptions -> IO ()
+runCat co = do
+  v <- Arion.Nix.evaluateComposition =<< defaultEvaluationArgs co
+  T.hPutStrLn stdout (pretty v)
+
+runRepl :: CommonOptions -> IO ()
+runRepl co = do
+  putErrText
+    "Launching a repl for you. To get started:\n\
+    \\n\
+    \To see deployment-wide configuration\n\
+    \  type config. and use tab completion\n\
+    \To bring the top-level Nixpkgs attributes into scope\n\
+    \  type :a (config._module.args.pkgs) // { inherit config; }\n\
+    \"
+  Arion.Nix.replForComposition =<< defaultEvaluationArgs co
+
+detachFlag :: Parser Bool
+detachFlag = flag False True (long "detach" <> short 'd' <> help "Detached mode: Run command in the background.")
+
+privilegedFlag :: Parser Bool
+privilegedFlag = flag False True (long "privileged" <> help "Give extended privileges to the process.")
+
+userOption :: Parser Text
+userOption = strOption (long "user" <> short 'u' <> help "Run the command as this user.")
+
+noTTYFlag :: Parser Bool
+noTTYFlag = flag False True (short 'T' <> help "Disable pseudo-tty allocation. By default `exec` allocates a TTY.")
+
+indexOption :: Parser Int
+indexOption = option
+  (auto >>= \i -> i <$ unless (i >= 1) (fail "container index must be >= 1"))
+  (long "index" <> value 1 <> help "Index of the container if there are multiple instances of a service.")
+
+envOption :: Parser (Text, Text)
+envOption = option (auto >>= spl) (long "env" <> short 'e' <> help "Set environment variables (can be used multiple times, not supported in Docker API < 1.25)")
+  where spl s = case T.break (== '=') s of
+          (_, "") -> fail "--env parameter needs to combine key and value with = sign"
+          (k, ev) -> pure (k, T.drop 1 ev)
+
+workdirOption :: Parser Text
+workdirOption = strOption (long "workdir" <> short 'w' <> metavar "DIR" <> help "Working directory in which to start the command in the container.")
+
+parseExecCommand :: Parser (CommonOptions -> IO ())
+parseExecCommand = runExec
+  <$> detachFlag
+  <*> privilegedFlag
+  <*> optional userOption
+  <*> noTTYFlag
+  <*> indexOption
+  <*> many envOption
+  <*> optional workdirOption
+  <*> textArgument (metavar "SERVICE")
+  <*> orEmpty' (
+      (:) <$> argument (T.pack <$> str) (metavar "COMMAND")
+          <*> many (argument (T.pack <$> str) (metavar "ARG"))
+  )
+
+orEmpty' :: (Alternative f, Monoid a) => f a -> f a
+orEmpty' m = fromMaybe mempty <$> optional m
+
+runExec :: Bool -> Bool -> Maybe Text -> Bool -> Int -> [(Text, Text)] -> Maybe Text -> Text -> [Text] -> CommonOptions -> IO ()
+runExec detach privileged user noTTY index envs workDir service commandAndArgs opts = do
+  putErrText $ "Service: " <> service
+
+  ea <- defaultEvaluationArgs opts
+  Arion.Nix.withEvaluatedComposition ea $ \path -> do
+    commandAndArgs'' <- case commandAndArgs of
+      [] -> getDefaultExec path service
+      x -> pure x
+    let commandAndArgs' = case commandAndArgs'' of
+          [] -> ["/bin/sh"]
+          x -> x
+  
+    let args = concat
+          [ ["exec"]
+          , ("--detach" <$ guard detach :: [Text])
+          , "--privileged" <$ guard privileged
+          , "-T" <$ guard noTTY
+          , (\(k, v) -> ["--env", k <> "=" <> v]) =<< envs
+          , join $ toList (user <&> \u -> ["--user", u])
+          , ["--index", show index]
+          , join $ toList (workDir <&> \w -> ["--workdir", w])
+          , [service]
+          , commandAndArgs'
+          ]
+    DockerCompose.run DockerCompose.Args
+      { files = [path]
+      , otherArgs = args
+      }
+
+main :: IO ()
+main = 
+  (join . arionExecParser) (info (parseAll <**> helper) fullDesc)
+  where
+    arionExecParser = customExecParser (prefs showHelpOnEmpty)
+
diff --git a/src/haskell/lib/Arion/Aeson.hs b/src/haskell/lib/Arion/Aeson.hs
new file mode 100644
--- /dev/null
+++ b/src/haskell/lib/Arion/Aeson.hs
@@ -0,0 +1,27 @@
+module Arion.Aeson where
+
+import Prelude ()
+import           Data.Aeson
+import qualified Data.ByteString.Lazy          as BL
+import qualified Data.Text.Lazy                as TL
+import qualified Data.Text.Lazy.Builder        as TB
+import qualified Data.Aeson.Encode.Pretty
+import           Data.Aeson.Encode.Pretty       ( defConfig
+                                                , confCompare
+                                                , confTrailingNewline
+                                                )
+import           Protolude
+
+pretty :: ToJSON a => a -> Text
+pretty =
+  TL.toStrict
+    . TB.toLazyText
+    . Data.Aeson.Encode.Pretty.encodePrettyToTextBuilder' config
+  where config = defConfig { confCompare = compare, confTrailingNewline = True }
+
+decodeFile :: FromJSON a => FilePath -> IO a
+decodeFile fp = do
+  b <- BL.readFile fp
+  case eitherDecode b of
+    Left e -> panic (toS e)
+    Right v -> pure v
diff --git a/src/haskell/lib/Arion/DockerCompose.hs b/src/haskell/lib/Arion/DockerCompose.hs
new file mode 100644
--- /dev/null
+++ b/src/haskell/lib/Arion/DockerCompose.hs
@@ -0,0 +1,30 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Arion.DockerCompose where
+
+import           Prelude                        ( )
+import           Protolude
+import           System.Process
+
+data Args = Args
+  { files :: [FilePath]
+  , otherArgs :: [Text]
+  }
+
+run :: Args -> IO ()
+run args = do
+  let fileArgs = files args >>= \f -> ["--file", f]
+      allArgs  = fileArgs ++ map toS (otherArgs args)
+
+      procSpec = proc "docker-compose" allArgs
+
+  -- hPutStrLn stderr ("Running docker-compose with " <> show allArgs :: Text)
+
+  withCreateProcess procSpec $ \_in _out _err procHandle -> do
+
+    exitCode <- waitForProcess procHandle
+
+    case exitCode of
+      ExitSuccess -> pass
+      ExitFailure 1 -> exitFailure
+      ExitFailure {} -> do
+        throwIO $ FatalError $ "docker-compose failed with " <> show exitCode
diff --git a/src/haskell/lib/Arion/Images.hs b/src/haskell/lib/Arion/Images.hs
new file mode 100644
--- /dev/null
+++ b/src/haskell/lib/Arion/Images.hs
@@ -0,0 +1,60 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE OverloadedStrings #-}
+module Arion.Images 
+  ( loadImages
+  ) where
+
+import Prelude()
+import Protolude hiding (to)
+
+import qualified Data.Aeson as Aeson
+import           Arion.Aeson (decodeFile)
+import qualified System.Process as Process
+
+import Control.Lens
+import Data.Aeson.Lens
+import Data.String
+import System.IO (withFile, IOMode(ReadMode))
+
+
+data Image = Image
+  { image :: Text -- ^ file path
+  , imageName :: Text
+  , imageTag :: Text
+  } deriving (Generic, Aeson.ToJSON, Aeson.FromJSON, Show)
+
+type TaggedImage = Text
+
+-- | Subject to change
+loadImages :: FilePath -> IO ()
+loadImages fp = do
+
+  v <- decodeFile fp
+
+  loaded <- dockerImages
+
+  let
+    images :: [Image]
+    images = (v :: Aeson.Value) ^.. key "x-arion" . key "images" . _Array . traverse . _JSON
+
+    isNew i = (imageName i <> ":" <> imageTag i) `notElem` loaded
+
+  traverse_ loadImage . map (toS . image) . filter isNew $ images
+
+loadImage :: FilePath -> IO ()
+loadImage imgPath = withFile (imgPath) ReadMode $ \fileHandle -> do
+  let procSpec = (Process.proc "docker" [ "load" ]) {
+          Process.std_in = Process.UseHandle fileHandle
+        }
+  Process.withCreateProcess procSpec $ \_in _out _err procHandle -> do
+    e <- Process.waitForProcess procHandle 
+    case e of
+      ExitSuccess -> pass
+      ExitFailure code -> panic $ "docker load (" <> show code <> ") failed for " <> toS imgPath
+
+
+dockerImages :: IO [TaggedImage]
+dockerImages = do
+  let procSpec = Process.proc "docker" [ "images",  "--filter", "dangling=false", "--format", "{{.Repository}}:{{.Tag}}" ]
+  (map toS . lines) <$> Process.readCreateProcess procSpec ""
diff --git a/src/haskell/lib/Arion/Nix.hs b/src/haskell/lib/Arion/Nix.hs
new file mode 100644
--- /dev/null
+++ b/src/haskell/lib/Arion/Nix.hs
@@ -0,0 +1,184 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Arion.Nix
+  ( evaluateComposition
+  , withEvaluatedComposition
+  , buildComposition
+  , withBuiltComposition
+  , replForComposition
+  , EvaluationArgs(..)
+  , EvaluationMode(..)
+  ) where
+
+import           Prelude                        ( )
+import           Protolude
+import           Arion.Aeson                    ( pretty )
+import           Data.Aeson
+import qualified Data.String
+import qualified System.Directory              as Directory
+import           System.Process
+import qualified Data.ByteString.Lazy          as BL
+import           Paths_arion_compose
+
+import qualified Data.Text.IO                  as T
+
+import qualified Data.List.NonEmpty            as NE
+import           Data.List.NonEmpty             ( NonEmpty(..) )
+
+import           Control.Arrow                  ( (>>>) )
+import           System.IO.Temp                 ( withTempFile )
+import           System.IO                      ( hClose )
+
+data EvaluationMode =
+  ReadWrite | ReadOnly
+
+data EvaluationArgs = EvaluationArgs
+ { evalUid :: Int
+ , evalModules :: NonEmpty FilePath
+ , evalPkgs :: Text
+ , evalWorkDir :: Maybe FilePath
+ , evalMode :: EvaluationMode
+ , evalUserArgs :: [Text]
+ }
+
+evaluateComposition :: EvaluationArgs -> IO Value
+evaluateComposition ea = do
+  evalComposition <- getEvalCompositionFile
+  let commandArgs =
+        [ "--eval"
+        , "--strict"
+        , "--json"
+        , "--attr"
+        , "config.out.dockerComposeYamlAttrs"
+        ]
+      args =
+        [ evalComposition ]
+        ++ commandArgs
+        ++ modeArguments (evalMode ea)
+        ++ argArgs ea
+        ++ map toS (evalUserArgs ea)
+      procSpec = (proc "nix-instantiate" args)
+        { cwd = evalWorkDir ea
+        , std_out = CreatePipe
+        }
+  
+  withCreateProcess procSpec $ \_in outHM _err procHandle -> do
+    let outHandle = fromMaybe (panic "stdout missing") outHM
+
+    out <- BL.hGetContents outHandle
+
+    v <- Protolude.evaluate (eitherDecode out)
+
+    exitCode <- waitForProcess procHandle
+
+    case exitCode of
+      ExitSuccess -> pass
+      ExitFailure 1 -> exitFailure
+      ExitFailure {} -> do
+        throwIO $ FatalError $ "evaluation failed with " <> show exitCode
+
+    case v of
+      Right r -> pure r
+      Left  e -> throwIO $ FatalError ("Couldn't parse nix-instantiate output" <> show e)
+
+-- | Run with docker-compose.yaml tmpfile
+withEvaluatedComposition :: EvaluationArgs -> (FilePath -> IO r) -> IO r
+withEvaluatedComposition ea f = do
+  v <- evaluateComposition ea
+  withTempFile "." ".tmp-arion-docker-compose.yaml" $ \path yamlHandle -> do
+    T.hPutStrLn yamlHandle (pretty v)
+    hClose yamlHandle
+    f path
+
+
+buildComposition :: FilePath -> EvaluationArgs -> IO ()
+buildComposition outLink ea = do
+  evalComposition <- getEvalCompositionFile
+  let commandArgs =
+        [ "--attr"
+        , "config.out.dockerComposeYaml"
+        , "--out-link"
+        , outLink
+        ]
+      args =
+        [ evalComposition ]
+        ++ commandArgs
+        ++ argArgs ea
+        ++ map toS (evalUserArgs ea)
+      procSpec = (proc "nix-build" args) { cwd = evalWorkDir ea }
+  
+  withCreateProcess procSpec $ \_in _out _err procHandle -> do
+
+    exitCode <- waitForProcess procHandle
+
+    case exitCode of
+      ExitSuccess -> pass
+      ExitFailure 1 -> exitFailure
+      ExitFailure {} -> do
+        throwIO $ FatalError $ "nix-build failed with " <> show exitCode
+
+-- | Do something with a docker-compose.yaml.
+withBuiltComposition :: EvaluationArgs -> (FilePath -> IO r) -> IO r
+withBuiltComposition ea f = do
+  withTempFile "." ".tmp-arion-docker-compose.yaml" $ \path emptyYamlHandle -> do
+    hClose emptyYamlHandle
+    -- Known problem: kills atomicity of withTempFile; won't fix because we should manage gc roots,
+    -- impl of which will probably avoid this "problem". It seems unlikely to cause issues.
+    Directory.removeFile path
+    buildComposition path ea
+    f path
+
+
+replForComposition :: EvaluationArgs -> IO ()
+replForComposition ea = do
+    evalComposition <- getEvalCompositionFile
+    let args =
+          [ "repl", evalComposition ]
+          ++ argArgs ea
+          ++ map toS (evalUserArgs ea)
+        procSpec = (proc "nix" args) { cwd = evalWorkDir ea }
+    
+    withCreateProcess procSpec $ \_in _out _err procHandle -> do
+
+      exitCode <- waitForProcess procHandle
+
+      case exitCode of
+        ExitSuccess -> pass
+        ExitFailure 1 -> exitFailure
+        ExitFailure {} -> do
+          throwIO $ FatalError $ "nix repl failed with " <> show exitCode
+
+argArgs :: EvaluationArgs -> [[Char]]
+argArgs ea =
+      [ "--argstr"
+      , "uid"
+      , show $ evalUid ea
+      , "--arg"
+      , "modules"
+      , modulesNixExpr $ evalModules ea
+      , "--arg"
+      , "pkgs"
+      , toS $ evalPkgs ea
+      ]
+
+getEvalCompositionFile :: IO FilePath
+getEvalCompositionFile = getDataFileName "nix/eval-composition.nix"
+
+modeArguments :: EvaluationMode -> [[Char]]
+modeArguments ReadWrite = [ "--read-write-mode" ]
+modeArguments ReadOnly = [ "--readonly-mode" ]
+
+modulesNixExpr :: NonEmpty FilePath -> [Char]
+modulesNixExpr =
+  NE.toList >>> fmap pathExpr >>> Data.String.unwords >>> wrapList
+ where
+  pathExpr :: FilePath -> [Char]
+  pathExpr path | isAbsolute path = "(/. + \"/${" <> toNixStringLiteral path <> "}\")"
+                | otherwise       = "(./. + \"/${" <> toNixStringLiteral path <> "}\")"
+
+  isAbsolute ('/' : _) = True
+  isAbsolute _         = False
+
+  wrapList s = "[ " <> s <> " ]"
+
+toNixStringLiteral :: [Char] -> [Char]
+toNixStringLiteral = show -- FIXME: custom escaping including '$'
diff --git a/src/haskell/lib/Arion/Services.hs b/src/haskell/lib/Arion/Services.hs
new file mode 100644
--- /dev/null
+++ b/src/haskell/lib/Arion/Services.hs
@@ -0,0 +1,23 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE OverloadedStrings #-}
+module Arion.Services
+  ( getDefaultExec
+  ) where
+
+import Prelude()
+import Protolude hiding (to)
+
+import qualified Data.Aeson as Aeson
+import           Arion.Aeson (decodeFile)
+
+import Control.Lens
+import Data.Aeson.Lens
+
+-- | Subject to change
+getDefaultExec :: FilePath -> Text -> IO [Text]
+getDefaultExec fp service = do
+
+  v <- decodeFile fp
+
+  pure ((v :: Aeson.Value) ^.. key "x-arion" . key "serviceInfo" . key service . key "defaultExec" . _Array . traverse . _String)
diff --git a/src/haskell/test/Arion/NixSpec.hs b/src/haskell/test/Arion/NixSpec.hs
new file mode 100644
--- /dev/null
+++ b/src/haskell/test/Arion/NixSpec.hs
@@ -0,0 +1,59 @@
+{-# LANGUAGE OverloadedStrings #-}
+module Arion.NixSpec
+  ( spec
+  )
+where
+
+import           Protolude
+import           Test.Hspec
+import qualified Data.List.NonEmpty            as NEL
+import           Arion.Aeson
+import           Arion.Nix
+import qualified Data.Text                     as T
+import qualified Data.Text.IO                  as T
+
+spec :: Spec
+spec = describe "evaluateComposition" $ it "matches an example" $ do
+  x <- Arion.Nix.evaluateComposition EvaluationArgs
+    { evalUid      = 123
+    , evalModules  = NEL.fromList
+                       ["src/haskell/testdata/Arion/NixSpec/arion-compose.nix"]
+    , evalPkgs     = "import <nixpkgs> {}"
+    , evalWorkDir  = Nothing
+    , evalMode     = ReadOnly
+    , evalUserArgs = ["--show-trace"]
+    }
+  let actual = pretty x
+  expected <- T.readFile "src/haskell/testdata/Arion/NixSpec/arion-compose.json"
+  censorPaths actual `shouldBe` censorPaths expected
+
+censorPaths :: Text -> Text
+censorPaths = censorImages . censorStorePaths
+
+censorStorePaths :: Text -> Text
+censorStorePaths x = case T.breakOn "/nix/store/" x of
+  (prefix, tl) | (tl :: Text) == "" -> prefix
+  (prefix, tl) -> prefix <> "<STOREPATH>" <> censorPaths
+    (T.dropWhile isNixNameChar $ T.drop (T.length "/nix/store/") tl)
+
+-- Probably slow, due to not O(1) <>
+censorImages :: Text -> Text
+censorImages x = case T.break (\c -> c == ':' || c == '"') x of
+  (prefix, tl) | tl == "" -> prefix
+  (prefix, tl) | let imageId = T.take 33 (T.drop 1 tl)
+               , T.last imageId == '\"'
+                 -- Approximation of nix hash validation
+               , T.all (\c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')) (T.take 32 imageId)
+               -> prefix <> T.take 1 tl <> "<HASH>" <> censorImages (T.drop 33 tl)
+  (prefix, tl) -> prefix <> T.take 1 tl <> censorImages (T.drop 1 tl)
+
+
+-- | WARNING: THIS IS LIKELY WRONG: DON'T REUSE
+isNixNameChar :: Char -> Bool
+isNixNameChar c | c >= '0' && c <= '9' = True
+isNixNameChar c | c >= 'a' && c <= 'z' = True
+isNixNameChar c | c >= 'A' && c <= 'Z' = True
+isNixNameChar c | c == '-' = True
+isNixNameChar c | c == '.' = True
+isNixNameChar c | c == '_' = True -- WRONG?
+isNixNameChar _ = False -- WRONG?
diff --git a/src/haskell/test/Spec.hs b/src/haskell/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/src/haskell/test/Spec.hs
@@ -0,0 +1,11 @@
+module Spec
+  ( spec
+  )
+where
+
+import           Test.Hspec
+import qualified Arion.NixSpec
+
+spec :: Spec
+spec = do
+  describe "Arion.Nix" Arion.NixSpec.spec
diff --git a/src/haskell/test/TestMain.hs b/src/haskell/test/TestMain.hs
new file mode 100644
--- /dev/null
+++ b/src/haskell/test/TestMain.hs
@@ -0,0 +1,10 @@
+module Main where
+
+import           Prelude()
+import           Protolude
+import           Test.Hspec.Runner
+import qualified Spec
+
+main :: IO ()
+main = hspecWith config Spec.spec
+  where config = defaultConfig { configColorMode = ColorAlways }
diff --git a/src/haskell/testdata/Arion/NixSpec/arion-compose.json b/src/haskell/testdata/Arion/NixSpec/arion-compose.json
new file mode 100644
--- /dev/null
+++ b/src/haskell/testdata/Arion/NixSpec/arion-compose.json
@@ -0,0 +1,49 @@
+{
+    "services": {
+        "webserver": {
+            "command": [
+                "/nix/store/b9w61w4g8sqgrm3rid6ca22krslqghb3-nixos-system-unnamed-19.03.173100.e726e8291b2/init"
+            ],
+            "environment": {
+                "NIX_REMOTE": "",
+                "PATH": "/usr/bin:/run/current-system/sw/bin/",
+                "container": "docker"
+            },
+            "image": "arion-base:<HASH>",
+            "ports": [
+                "8000:80"
+            ],
+            "stop_signal": "SIGRTMIN+3",
+            "sysctls": {},
+            "tmpfs": [
+                "/run",
+                "/run/wrappers",
+                "/tmp:exec,mode=777"
+            ],
+            "tty": true,
+            "volumes": [
+                "/sys/fs/cgroup:/sys/fs/cgroup:ro",
+                "/nix/store:/nix/store:ro",
+                "/nix/store/pssdmhzjnhflawv7rwk1yw39350iv40g-container-system-env:/run/system:ro"
+            ]
+        }
+    },
+    "version": "3.4",
+    "x-arion": {
+        "images": [
+            {
+                "image": "<STOREPATH>",
+                "imageName": "arion-base",
+                "imageTag": "<HASH>"
+            }
+        ],
+        "serviceInfo": {
+            "webserver": {
+                "defaultExec": [
+                    "/run/current-system/sw/bin/bash",
+                    "-l"
+                ]
+            }
+        }
+    }
+}
diff --git a/src/haskell/testdata/Arion/NixSpec/arion-compose.nix b/src/haskell/testdata/Arion/NixSpec/arion-compose.nix
new file mode 100644
--- /dev/null
+++ b/src/haskell/testdata/Arion/NixSpec/arion-compose.nix
@@ -0,0 +1,12 @@
+{
+  docker-compose.services.webserver = { pkgs, ... }: {
+    nixos.useSystemd = true;
+    nixos.configuration.boot.tmpOnTmpfs = true;
+    nixos.configuration.services.nginx.enable = true;
+    nixos.configuration.services.nginx.virtualHosts.localhost.root = "${pkgs.nix.doc}/share/doc/nix/manual";
+    service.useHostStore = true;
+    service.ports = [
+      "8000:80" # host:container
+    ];
+  };
+}
diff --git a/src/haskell/testdata/docker-compose-example.json b/src/haskell/testdata/docker-compose-example.json
new file mode 100644
--- /dev/null
+++ b/src/haskell/testdata/docker-compose-example.json
@@ -0,0 +1,42 @@
+{
+  "services": {
+    "webserver": {
+      "environment": {
+        "container": "docker"
+      },
+      "image": "webserver:xr4ljmz3qfcwlq9rl4mr4qdrzw93rl70",
+      "ports": [
+        "8000:80"
+      ],
+      "stop_signal": "SIGRTMIN+3",
+      "sysctls": {},
+      "tmpfs": [
+        "/run",
+        "/run/wrappers",
+        "/tmp:exec,mode=777"
+      ],
+      "tty": true,
+      "volumes": [
+        "/sys/fs/cgroup:/sys/fs/cgroup:ro"
+      ]
+    }
+  },
+  "version": "3.4",
+  "x-arion": {
+    "images": [
+      {
+        "image": "/nix/store/xr4ljmz3qfcwlq9rl4mr4qdrzw93rl70-docker-image-webserver.tar.gz",
+        "imageName": "webserver",
+        "imageTag": "xr4ljmz3qfcwlq9rl4mr4qdrzw93rl70"
+      }
+    ],
+    "serviceInfo": {
+      "webserver": {
+        "defaultExec": [
+          "/run/current-system/sw/bin/bash",
+          "-l"
+        ]
+      }
+    }
+  }
+}
diff --git a/src/nix/eval-composition.nix b/src/nix/eval-composition.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/eval-composition.nix
@@ -0,0 +1,42 @@
+{ modules ? [], uid ? "0", pkgs, hostNixStorePrefix ? "", }:
+
+let _pkgs = pkgs;
+in
+let
+  pkgs = if builtins.typeOf _pkgs == "path"
+         then import _pkgs
+         else if builtins.typeOf _pkgs == "set"
+         then _pkgs
+         else builtins.abort "The pkgs argument must be an attribute set or a path to an attribute set.";
+
+  inherit (pkgs) lib;
+
+  composition = lib.evalModules {
+    check = true;
+    modules = builtinModules ++ modules;
+  };
+
+  builtinModules = [
+    argsModule
+    ./modules/composition/docker-compose.nix
+    ./modules/composition/host-environment.nix
+    ./modules/composition/images.nix
+    ./modules/composition/service-info.nix
+    ./modules/composition/arion-base-image.nix
+  ];
+
+  argsModule = {
+    _file = ./eval-composition.nix;
+    key = ./eval-composition.nix;
+    config._module.args.pkgs = lib.mkIf (pkgs != null) (lib.mkForce pkgs);
+    config.host.nixStorePrefix = hostNixStorePrefix;
+    config.host.uid = lib.toInt uid;
+  };
+
+in
+  # Typically you need composition.config.out.dockerComposeYaml
+  composition // {
+    # throw in lib and pkgs for repl convenience
+    inherit lib;
+    inherit (composition.config._module.args) pkgs;
+  }
diff --git a/src/nix/modules/composition/arion-base-image.nix b/src/nix/modules/composition/arion-base-image.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/composition/arion-base-image.nix
@@ -0,0 +1,41 @@
+
+
+# This module is subject to change.
+# In particular, arion-base should use a generic non-service image building system
+
+{ config, lib, pkgs, ... }: 
+
+let
+
+  tag = lib.head (lib.strings.splitString "-" (baseNameOf builtImage.outPath));
+  name = "arion-base";
+
+  builtImage = pkgs.dockerTools.buildLayeredImage {
+    inherit name;
+    contents = pkgs.runCommand "minimal-contents" {} ''
+        mkdir -p $out/bin $out/usr/bin
+        ln -s /run/system/bin/sh $out/bin/sh
+        ln -s /run/system/usr/bin/env $out/usr/bin/env
+      '';
+    config = {};
+  };
+
+in
+
+{
+
+  options = {
+    arionBaseImage = lib.mkOption {
+      type = lib.types.str;
+      description = "Image to use when using useHostStore. Don't use this option yourself. It's going away.";
+      internal = true;
+    };
+  };
+
+  config = {
+    arionBaseImage = "${name}:${tag}";
+    build.imagesToLoad = lib.mkIf (lib.any (s: s.service.useHostStore) (lib.attrValues config.services)) [
+      { image = builtImage; imageName = name; imageTag = tag; }
+    ];  
+  };
+}
diff --git a/src/nix/modules/composition/docker-compose.nix b/src/nix/modules/composition/docker-compose.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/composition/docker-compose.nix
@@ -0,0 +1,78 @@
+/*
+
+   This is a composition-level module.
+
+   It defines the low-level options that are read by arion, like
+    - out.dockerComposeYaml
+
+   It declares options like
+    - services
+
+ */
+compositionArgs@{ lib, config, options, pkgs, ... }:
+let
+  inherit (lib) types;
+
+  service = {
+    imports = [ argsModule ] ++ import ../service/all-modules.nix;
+  };
+  argsModule =
+    { name, # injected by types.submodule
+      ...
+    }: {
+      _file = ./docker-compose.nix;
+      key = ./docker-compose.nix;
+
+      config._module.args.pkgs = lib.mkDefault compositionArgs.pkgs;
+      config.host = compositionArgs.config.host;
+      config.composition = compositionArgs.config;
+      config.service.name = name;
+    };
+
+in
+{
+  imports = [
+    ../lib/assert.nix
+    (lib.mkRenamedOptionModule ["docker-compose" "services"] ["services"])
+  ];
+  options = {
+    out.dockerComposeYaml = lib.mkOption {
+      type = lib.types.package;
+      description = "A derivation that produces a docker-compose.yaml file for this composition.";
+      readOnly = true;
+    };
+    out.dockerComposeYamlText = lib.mkOption {
+      type = lib.types.str;
+      description = "The text of out.dockerComposeYaml.";
+      readOnly = true;
+    };
+    out.dockerComposeYamlAttrs = lib.mkOption {
+      type = lib.types.attrsOf lib.types.unspecified;
+      description = "The text of out.dockerComposeYaml.";
+      readOnly = true;
+    };
+    docker-compose.raw = lib.mkOption {
+      type = lib.types.attrs;
+      description = "Attribute set that will be turned into the docker-compose.yaml file, using Nix's toJSON builtin.";
+    };
+    docker-compose.extended = lib.mkOption {
+      type = lib.types.attrs;
+      description = "Attribute set that will be turned into the x-arion section of the docker-compose.yaml file.";
+    };
+    services = lib.mkOption {
+      type = lib.types.attrsOf (lib.types.submodule service);
+      description = "An attribute set of service configurations. A service specifies how to run an image as a container.";
+    };
+  };
+  config = {
+    out.dockerComposeYaml = pkgs.writeText "docker-compose.yaml" config.out.dockerComposeYamlText;
+    out.dockerComposeYamlText = builtins.toJSON (config.out.dockerComposeYamlAttrs);
+    out.dockerComposeYamlAttrs = config.assertWarn config.docker-compose.raw;
+
+    docker-compose.raw = {
+      version = "3.4";
+      services = lib.mapAttrs (k: c: c.out.service) config.services;
+      x-arion = config.docker-compose.extended;
+    };
+  };
+}
diff --git a/src/nix/modules/composition/host-environment.nix b/src/nix/modules/composition/host-environment.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/composition/host-environment.nix
@@ -0,0 +1,33 @@
+{ lib, ... }:
+
+{
+  options = {
+
+    host.uid = lib.mkOption {
+      type = lib.types.int;
+      description = ''
+        The numeric user id (UID) of the user running arion.
+
+        This lets you to write modules that interact with the host
+        user's files, which is helpful for local development, but not
+        intended for production-like deployment scenarios.
+      '';
+    };
+
+    host.nixStorePrefix = lib.mkOption {
+      type = lib.types.str;
+      default = "";
+      example = "/mnt/foo";
+      description = ''
+        Prefixes store paths on the host, allowing the Nix store to be
+        stored at an alternate location without altering the format of
+        store paths.
+
+        For example: instead of mounting the host's /nix/store as the
+        container's /nix/store, this will mount /mnt/foo/nix/store
+        as the container's /nix/store.
+      '';
+    };
+
+  };
+}
diff --git a/src/nix/modules/composition/images.nix b/src/nix/modules/composition/images.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/composition/images.nix
@@ -0,0 +1,39 @@
+{ pkgs, lib, config, ... }:
+let
+  inherit (lib.types) listOf package unspecified;
+
+  serviceImages =
+    lib.mapAttrs addDetails (
+      lib.filterAttrs filterFunction config.services
+    );
+
+  filterFunction = serviceName: service:
+    builtins.addErrorContext "while evaluating whether the service ${serviceName} defines an image"
+      service.image.nixBuild;
+
+  addDetails = serviceName: service:
+    builtins.addErrorContext "while evaluating the image for service ${serviceName}"
+    (let
+      inherit (service) build;
+    in {
+      image = build.image.outPath;
+      imageName = build.imageName or service.image.name;
+      imageTag =
+                 if build.image.imageTag != ""
+                 then build.image.imageTag
+                 else lib.head (lib.strings.splitString "-" (baseNameOf build.image.outPath));
+    });
+in
+{
+  options = {
+    build.imagesToLoad = lib.mkOption {
+      type = listOf unspecified;
+      internal = true;
+      description = "List of dockerTools image derivations.";
+    };
+  };
+  config = {
+    build.imagesToLoad = lib.attrValues serviceImages;
+    docker-compose.extended.images = config.build.imagesToLoad;
+  };
+}
diff --git a/src/nix/modules/composition/service-info.nix b/src/nix/modules/composition/service-info.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/composition/service-info.nix
@@ -0,0 +1,21 @@
+/*
+   Adds extendedInfo from services to the Docker Compose file.
+
+   This contains fields that are not in Docker Compose schema.
+ */
+{ config, lib, ... }:
+let
+  inherit (lib) mapAttrs filterAttrs;
+
+  serviceInfo =
+    filterAttrs (_k: v: v != {})
+      (mapAttrs (_serviceName: service: service.out.extendedInfo)
+        config.services
+      );
+
+in
+{
+  config = {
+    docker-compose.extended.serviceInfo = serviceInfo;
+  };
+}
diff --git a/src/nix/modules/lib/assert.nix b/src/nix/modules/lib/assert.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/lib/assert.nix
@@ -0,0 +1,32 @@
+{ config, lib, pkgs, ... }:
+
+# based on nixpkgs/nixos/modules/system/activation/top-level.nix
+
+let
+  inherit (lib) filter concatStringsSep types mkOption;
+
+  # lib.showWarnings since 19.09
+  showWarnings = warnings: res: lib.fold (w: x: lib.warn w x) res warnings;
+  warn = msg: builtins.trace "[1;31mwarning: ${msg}[0m";
+
+  # Handle assertions and warnings
+
+  failedAssertions = map (x: x.message) (filter (x: !x.assertion) config.assertions);
+
+  assertWarn = if failedAssertions != []
+    then throw "\nFailed assertions:\n${concatStringsSep "\n" (map (x: "- ${x}") failedAssertions)}"
+    else showWarnings config.warnings;
+
+in
+
+{
+  imports = [ ./assertions.nix ];
+  options.assertWarn = mkOption {
+    type = types.unspecified; # a function
+    # It's for the wrapping program to know about this. User need not care.
+    internal = true;
+    readOnly = true;
+  };
+  config = { inherit assertWarn; };
+}
+
diff --git a/src/nix/modules/lib/assertions.nix b/src/nix/modules/lib/assertions.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/lib/assertions.nix
@@ -0,0 +1,34 @@
+{ lib, ... }:
+
+with lib;
+
+{
+
+  options = {
+
+    assertions = mkOption {
+      type = types.listOf types.unspecified;
+      internal = true;
+      default = [];
+      example = [ { assertion = false; message = "you can't enable this for that reason"; } ];
+      description = ''
+        This option allows modules to express conditions that must
+        hold for the evaluation of the system configuration to
+        succeed, along with associated error messages for the user.
+      '';
+    };
+
+    warnings = mkOption {
+      internal = true;
+      default = [];
+      type = types.listOf types.str;
+      example = [ "The `foo' service is deprecated and will go away soon!" ];
+      description = ''
+        This option allows modules to show warnings to users during
+        the evaluation of the system configuration.
+      '';
+    };
+
+  };
+  # impl of assertions is in <nixpkgs/nixos/modules/system/activation/top-level.nix>
+}
diff --git a/src/nix/modules/nixos/container-systemd.nix b/src/nix/modules/nixos/container-systemd.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/nixos/container-systemd.nix
@@ -0,0 +1,24 @@
+/*
+  NixOS configuration to for running a mostly normal systemd-based
+  NixOS in Docker.
+ */
+{ pkgs, lib, ...}: {
+
+  # imports = [
+  #   # This profile doesn't seem to work well.
+  #   (pkgs.path + "/nixos/modules/profiles/docker-container.nix")
+  #   This one works, but can not be imported here due because imports can not depend on pkgs.
+  #   (pkgs.path + "/nixos/modules/profiles/minimal.nix")
+  # ];
+  boot.isContainer = true;
+  boot.specialFileSystems = lib.mkForce {};
+  networking.hostName = "";
+
+  services.journald.console = "/dev/console";
+
+  systemd.services.systemd-logind.enable = false;
+  systemd.services.console-getty.enable = false;
+
+  systemd.sockets.nix-daemon.enable = lib.mkDefault false;
+  systemd.services.nix-daemon.enable = lib.mkDefault false;
+}
diff --git a/src/nix/modules/nixos/default-shell.nix b/src/nix/modules/nixos/default-shell.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/nixos/default-shell.nix
@@ -0,0 +1,4 @@
+{ config, utils, ... }:
+{
+  config.system.build.x-arion-defaultShell = utils.toShellPath config.users.defaultUserShell;
+}
diff --git a/src/nix/modules/service/all-modules.nix b/src/nix/modules/service/all-modules.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/service/all-modules.nix
@@ -0,0 +1,11 @@
+[
+  ./default-exec.nix
+  ./docker-compose-service.nix
+  ./extended-info.nix
+  ./host-store.nix
+  ./context.nix
+  ./image.nix
+  ./nixos.nix
+  ./nixos-init.nix
+  ../lib/assert.nix
+]
diff --git a/src/nix/modules/service/context.nix b/src/nix/modules/service/context.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/service/context.nix
@@ -0,0 +1,19 @@
+{ lib, ... }:
+{
+  options = {
+    host = lib.mkOption {
+      type = lib.types.attrs;
+      readOnly = true;
+      description = ''
+        The composition-level host option values.
+      '';
+    };
+    composition = lib.mkOption {
+      type = lib.types.attrs;
+      readOnly = true;
+      description = ''
+        The composition configuration.
+      '';
+    };
+  };
+}
diff --git a/src/nix/modules/service/default-exec.nix b/src/nix/modules/service/default-exec.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/service/default-exec.nix
@@ -0,0 +1,19 @@
+{ config, lib, ... }:
+let
+  inherit (lib) types mkOption;
+in
+{
+  options = {
+    service.defaultExec = mkOption {
+      type = types.listOf types.str;
+      default = ["/bin/sh"];
+      description = ''
+        Container program and arguments to invoke when calling
+        <code>arion exec &lt;service.name></code> without further arguments.
+      '';
+    };
+  };
+  config = {
+    out.extendedInfo.defaultExec = config.service.defaultExec;
+  };
+}
diff --git a/src/nix/modules/service/docker-compose-service.nix b/src/nix/modules/service/docker-compose-service.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/service/docker-compose-service.nix
@@ -0,0 +1,273 @@
+/*
+
+   This service-level module defines the out.service option, using
+   the user-facing options service.image, service.volumes, etc.
+
+ */
+{ pkgs, lib, config, ... }:
+
+let
+  inherit (lib) mkOption types;
+  inherit (types) listOf nullOr attrsOf str either int bool;
+
+  link = url: text:
+    ''<link xlink:href="${url}">${text}</link>'';
+  dockerComposeRef = fragment:
+    ''See <link xlink:href="https://docs.docker.com/compose/compose-file/#${fragment}">Docker Compose#${fragment}</link>'';
+  dockerComposeKitchenSink = ''
+    Analogous to the <code>docker run</code> counterpart.
+
+    ${dockerComposeRef "domainname-hostname-ipc-mac_address-privileged-read_only-shm_size-stdin_open-tty-user-working_dir"}
+  '';
+
+  cap_add = lib.attrNames (lib.filterAttrs (name: value: value == true) config.service.capabilities);
+  cap_drop = lib.attrNames (lib.filterAttrs (name: value: value == false) config.service.capabilities);
+
+in
+{
+  imports = [
+    (lib.mkRenamedOptionModule ["build" "service"] ["out" "service"])
+  ];
+
+  options = {
+    out.service = mkOption {
+      type = attrsOf types.unspecified;
+      description = ''
+        Raw input for the service in <code>docker-compose.yaml</code>.
+
+        You should not need to use this option. If anything is
+        missing, please contribute the missing option.
+
+        This option is user accessible because it may serve as an
+        escape hatch for some.
+      '';
+      apply = config.assertWarn;
+    };
+
+    service.name = mkOption {
+      type = str;
+      description = ''
+        The name of the service - <code>&lt;name></code> in the composition-level <code>services.&lt;name></code>
+      '';
+      readOnly = true;
+    };
+
+    service.volumes = mkOption {
+      type = listOf types.unspecified;
+      default = [];
+      description = dockerComposeRef "volumes";
+    };
+    service.tmpfs = mkOption {
+      type = listOf types.str;
+      default = [];
+      description = dockerComposeRef "tmpfs";
+    };
+    service.build.context = mkOption {
+      type = nullOr str;
+      default = null;
+      description = ''
+        Locates a Dockerfile to use for creating an image to use in this service.
+
+        ${dockerComposeRef "context"}
+      '';
+    };
+    service.hostname = mkOption {
+      type = nullOr str;
+      default = null;
+      description = dockerComposeKitchenSink;
+    };
+    service.tty = mkOption {
+      type = nullOr bool;
+      default = null;
+      description = dockerComposeKitchenSink;
+    };
+    service.environment = mkOption {
+      type = attrsOf (either str int);
+      default = {};
+      description = dockerComposeRef "environment";
+    };
+    service.image = mkOption {
+      type = str;
+      description = dockerComposeRef "image";
+    };
+    service.command = mkOption {
+      type = nullOr types.unspecified;
+      default = null;
+      description = dockerComposeRef "command";
+    };
+    service.container_name = mkOption {
+      type = nullOr types.str;
+      default = null;
+      description = dockerComposeRef "container_name";
+    };
+    service.depends_on = mkOption {
+      type = listOf str;
+      default = [];
+      description = dockerComposeRef "depends_on";
+    };
+    service.devices = mkOption {
+      type = listOf str;
+      default = [];
+      description = ''
+        See ${link "https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities"
+        "<code>docker run --device</code> documentation"}
+
+        ${dockerComposeRef "devices"}
+      '';
+    };
+    service.links = mkOption {
+      type = listOf str;
+      default = [];
+      description = dockerComposeRef "links";
+    };
+    service.external_links = mkOption {
+      type = listOf str;
+      default = [];
+      description = dockerComposeRef "external_links";
+    };
+    service.extra_hosts = mkOption {
+      type = listOf str;
+      default = [];
+      description = dockerComposeRef "extra_hosts";
+    };
+    service.working_dir = mkOption {
+      type = nullOr str;
+      default = null;
+      description = dockerComposeKitchenSink;
+    };
+    service.privileged = mkOption {
+      type = nullOr bool;
+      default = null;
+      description = dockerComposeKitchenSink;
+    };
+    service.entrypoint = mkOption {
+      type = nullOr str;
+      default = null;
+      description = dockerComposeRef "entrypoint";
+    };
+    service.restart = mkOption {
+      type = nullOr str;
+      default = null;
+      description = dockerComposeRef "restart";
+    };
+    service.user = mkOption {
+      type = nullOr str;
+      default = null;
+      description = dockerComposeKitchenSink;
+    };
+    service.ports = mkOption {
+      type = listOf types.unspecified;
+      default = [];
+      description = ''
+        Expose ports on host. "host:container" or structured.
+
+        ${dockerComposeRef "ports"}
+      '';
+    };
+    service.expose = mkOption {
+      type = listOf str;
+      default = [];
+      description = dockerComposeRef "expose";
+    };
+    service.env_file = mkOption {
+      type = listOf str;
+      default = [];
+      description = dockerComposeRef "env_file";
+    };
+    service.network_mode = mkOption {
+      type = nullOr str;
+      default = null;
+      description = dockerComposeRef "network_mode";
+    };
+    service.networks = mkOption {
+      type = nullOr (listOf types.str);
+      default = null;
+      description = dockerComposeRef "networks";
+    };
+    service.stop_signal = mkOption {
+      type = nullOr str;
+      default = null;
+      description = dockerComposeRef "stop_signal";
+    };
+    service.sysctls = mkOption {
+      type = attrsOf (either str int);
+      default = {};
+      description = dockerComposeRef "sysctls";
+    };
+    service.capabilities = mkOption {
+      type = attrsOf (nullOr bool);
+      default = {};
+      example = { ALL = true; SYS_ADMIN = false; NET_ADMIN = false; };
+      description = ''
+        Enable/disable linux capabilities, or pick Docker's default.
+
+        Setting a capability to <code>true</code> means that it will be
+        "added". Setting it to <code>false</code> means that it will be "dropped".
+        ${dockerComposeRef "cap_add-cap_drop"}
+
+        Omitted and <code>null</code> capabilities will therefore be set
+        according to Docker's ${
+          link "https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities"
+               "default list of capabilities."
+        }
+      '';
+    };
+  };
+
+  config.out.service = {
+    inherit (config.service)
+      volumes
+      environment
+      sysctls
+      image
+      ;
+  } // lib.optionalAttrs (config.service.build.context != null) {
+    inherit (config.service) build;
+  } // lib.optionalAttrs (cap_add != []) {
+    inherit cap_add;
+  } // lib.optionalAttrs (cap_drop != []) {
+    inherit cap_drop;
+  } // lib.optionalAttrs (config.service.command != null) {
+    inherit (config.service) command;
+  } // lib.optionalAttrs (config.service.container_name != null) {
+    inherit (config.service) container_name;
+  } // lib.optionalAttrs (config.service.depends_on != []) {
+    inherit (config.service) depends_on;
+  } // lib.optionalAttrs (config.service.devices != []) {
+    inherit (config.service) devices;
+  } // lib.optionalAttrs (config.service.entrypoint != null) {
+    inherit (config.service) entrypoint;
+  } // lib.optionalAttrs (config.service.env_file != []) {
+    inherit (config.service) env_file;
+  } // lib.optionalAttrs (config.service.expose != []) {
+    inherit (config.service) expose;
+  } // lib.optionalAttrs (config.service.external_links != []) {
+    inherit (config.service) external_links;
+  } // lib.optionalAttrs (config.service.extra_hosts != []) {
+    inherit (config.service) extra_hosts;
+  } // lib.optionalAttrs (config.service.hostname != null) {
+    inherit (config.service) hostname;
+  } // lib.optionalAttrs (config.service.links != []) {
+    inherit (config.service) links;
+  } // lib.optionalAttrs (config.service.ports != []) {
+    inherit (config.service) ports;
+  } // lib.optionalAttrs (config.service.privileged != null) {
+    inherit (config.service) privileged;
+  } // lib.optionalAttrs (config.service.network_mode != null) {
+    inherit (config.service) network_mode;
+  } // lib.optionalAttrs (config.service.networks != null) {
+    inherit (config.service) networks;
+  } // lib.optionalAttrs (config.service.restart != null) {
+    inherit (config.service) restart;
+  } // lib.optionalAttrs (config.service.stop_signal != null) {
+    inherit (config.service) stop_signal;
+  } // lib.optionalAttrs (config.service.tmpfs != []) {
+    inherit (config.service) tmpfs;
+  } // lib.optionalAttrs (config.service.tty != null) {
+    inherit (config.service) tty;
+  } // lib.optionalAttrs (config.service.working_dir != null) {
+    inherit (config.service) working_dir;
+  } // lib.optionalAttrs (config.service.user != null) {
+    inherit (config.service) user;
+  };
+}
diff --git a/src/nix/modules/service/extended-info.nix b/src/nix/modules/service/extended-info.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/service/extended-info.nix
@@ -0,0 +1,23 @@
+{ config, lib, ... }:
+let
+  inherit (lib) mkOption;
+  inherit (lib.types) attrsOf unspecified;
+in
+{
+  imports = [
+    (lib.mkRenamedOptionModule ["build" "extendedInfo"] ["out" "extendedInfo"])
+  ];
+  options = {
+    out.extendedInfo = mkOption {
+      type = attrsOf unspecified;
+      description = ''
+        Information about a service to include in the Docker Compose file,
+        but that will not be used by the <code>docker-compose</code> command
+        itself.
+
+        It will be inserted in <code>x-arion.serviceInfo.&lt;service.name></code>.
+      '';
+      default = {};
+    };
+  };
+}
diff --git a/src/nix/modules/service/host-store.nix b/src/nix/modules/service/host-store.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/service/host-store.nix
@@ -0,0 +1,41 @@
+/*
+
+   This service-level bind mounts the host store into the container
+   when the service.useHostStore option is set to true.
+
+ */
+{ lib, config, pkgs, ... }:
+
+let
+  inherit (lib) mkOption types mkIf;
+  escape = s: lib.replaceStrings ["$"] ["$$"] s;
+in
+{
+  options = {
+    service.useHostStore = mkOption {
+      type = types.bool;
+      default = false;
+      description = "Bind mounts the host store if enabled, avoiding copying.";
+    };
+    service.hostStoreAsReadOnly = mkOption {
+      type = types.bool;
+      default = true;
+      description = "Adds a ':ro' (read-only) access mode to the host nix store bind mount.";
+    };
+    service.useHostNixDaemon = mkOption {
+      type = types.bool;
+      default = false;
+      description = "Make the host Nix daemon available.";
+    };
+  };
+  config = mkIf config.service.useHostStore {
+    image.nixBuild = false; # no need to build and load
+    service.image = config.composition.arionBaseImage;
+    service.environment.NIX_REMOTE = lib.optionalString config.service.useHostNixDaemon "daemon";
+    service.volumes = [
+      "${config.host.nixStorePrefix}/nix/store:/nix/store${lib.optionalString config.service.hostStoreAsReadOnly ":ro"}"
+      "${config.host.nixStorePrefix}${pkgs.buildEnv { name = "container-system-env"; paths = [ pkgs.bashInteractive pkgs.coreutils ]; }}:/run/system${lib.optionalString config.service.hostStoreAsReadOnly ":ro"}"
+      ] ++ lib.optional config.service.useHostNixDaemon "/nix/var/nix/daemon-socket:/nix/var/nix/daemon-socket";
+    service.command = lib.mkDefault (map escape (config.image.rawConfig.Cmd or []));
+  };
+}
diff --git a/src/nix/modules/service/image.nix b/src/nix/modules/service/image.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/service/image.nix
@@ -0,0 +1,122 @@
+{ pkgs, lib, config, options, ... }:
+let
+  inherit (lib) types mkOption;
+  inherit (types) attrsOf listOf nullOr package str unspecified bool;
+
+  # TODO: dummy-config is a useless layer. Nix 2.3 will let us inspect
+  #       the string context instead, so we can avoid this.
+  contentsList = config.image.contents ++ [
+    (pkgs.writeText "dummy-config.json" (builtins.toJSON config.image.rawConfig))
+  ];
+
+  builtImage = pkgs.dockerTools.buildLayeredImage {
+    inherit (config.image)
+      name
+      contents
+      ;
+    config = config.image.rawConfig;
+    maxLayers = 100;
+
+    # TODO: allow use of image's Nix package instead
+    # TODO: option to disable db generation
+    extraCommands = ''
+        echo "Generating the nix database..."
+        echo "Warning: only the database of the deepest Nix layer is loaded."
+        echo "         If you want to use nix commands in the container, it would"
+        echo "         be better to only have one layer that contains a nix store."
+        export NIX_REMOTE=local?root=$PWD
+        ${pkgs.nix}/bin/nix-store --load-db < ${pkgs.closureInfo {rootPaths = contentsList;}}/registration
+        mkdir -p nix/var/nix/gcroots/docker/
+        for i in ${lib.concatStringsSep " " contentsList}; do
+          ln -s $i nix/var/nix/gcroots/docker/$(basename $i)
+        done;
+      '';
+  };
+
+  priorityIsDefault = option: option.highestPrio >= (lib.mkDefault true).priority;
+in
+{
+  options = {
+    build.image = mkOption {
+      type = nullOr package;
+      description = ''
+        Docker image derivation to be <code>docker load</code>ed.
+      '';
+      internal = true;
+    };
+    build.imageName = mkOption {
+      type = str;
+      description = "Derived from build.image";
+      internal = true;
+    };
+    build.imageTag = mkOption {
+      type = str;
+      description = "Derived from build.image";
+      internal = true;
+    };
+    image.nixBuild = mkOption {
+      type = bool;
+      description = ''
+        Whether to build this image with Nixpkgs'
+        <code>dockerTools.buildLayeredImage</code>
+        and then load it with <code>docker load</code>.
+
+        By default, an image will be built with Nix unless <option>service.image</option>
+        is set. See also <option>image.name</option>, which defaults to
+        the service name.
+      '';
+    };
+    image.name = mkOption {
+      type = str;
+      default = config.service.name;
+      defaultText = lib.literalExample "config.service.name";
+      description = ''
+        A human readable name for the docker image.
+
+        Shows up in the <code>docker ps</code> output in the
+        <code>IMAGE</code> column, among other places.
+      '';
+    };
+    image.contents = mkOption {
+      type = listOf package;
+      default = [];
+      description = ''
+         Top level paths in the container.
+      '';
+    };
+    image.rawConfig = mkOption {
+      type = attrsOf unspecified;
+      default = {};
+      description = ''
+        This is a low-level fallback for when a container option has not
+        been modeled in the Arion module system.
+
+        This attribute set does not have an appropriate merge function.
+        Please use the specific <code>image</code> options instead.
+
+        Run-time configuration of the container. A full list of the
+        options are available at in the <link xlink:href="https://github.com/moby/moby/blob/master/image/spec/v1.2.md#image-json-field-descriptions">Docker Image Specification
+        v1.2.0</link>.
+      '';
+    };
+    image.command = mkOption {
+      type = listOf str;
+      default = [];
+      description = ''
+      '';
+    };
+  };
+  config = {
+    build.image = builtImage;
+    build.imageName = config.build.image.imageName;
+    build.imageTag =
+                 if config.build.image.imageTag != ""
+                 then config.build.image.imageTag
+                 else lib.head (lib.strings.splitString "-" (baseNameOf config.build.image.outPath));
+
+    service.image = lib.mkDefault "${config.build.imageName}:${config.build.imageTag}";
+    image.rawConfig.Cmd = config.image.command;
+
+    image.nixBuild = lib.mkDefault (priorityIsDefault options.service.image);
+  };
+}
diff --git a/src/nix/modules/service/nixos-init.nix b/src/nix/modules/service/nixos-init.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/service/nixos-init.nix
@@ -0,0 +1,42 @@
+/*
+  Invokes the NixOS init system in the container.
+ */
+{ config, lib, pkgs, ... }:
+let
+  inherit (lib) types;
+in
+{
+  options = {
+    nixos.useSystemd = lib.mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        When enabled, call the NixOS systemd-based init system.
+
+        Configure NixOS with <code>nixos.configuration</code>.
+      '';
+    };
+  };
+
+  config = lib.mkIf (config.nixos.useSystemd) {
+    nixos.configuration.imports = [
+      ../nixos/container-systemd.nix
+      ../nixos/default-shell.nix
+      (pkgs.path + "/nixos/modules/profiles/minimal.nix")
+    ];
+    image.command = [ "${config.nixos.build.toplevel}/init" ];
+    service.environment.container = "docker";
+    service.environment.PATH = "/usr/bin:/run/current-system/sw/bin/";
+    service.volumes = [
+      "/sys/fs/cgroup:/sys/fs/cgroup:ro"
+    ];
+    service.tmpfs = [
+      "/run"          # noexec is fine because exes should be symlinked from elsewhere anyway
+      "/run/wrappers" # noexec breaks this intentionally
+    ] ++ lib.optional (config.nixos.evaluatedConfig.boot.tmpOnTmpfs) "/tmp:exec,mode=777";
+
+    service.stop_signal = "SIGRTMIN+3";
+    service.tty = true;
+    service.defaultExec = [config.nixos.build.x-arion-defaultShell "-l"];
+  };
+}
diff --git a/src/nix/modules/service/nixos.nix b/src/nix/modules/service/nixos.nix
new file mode 100644
--- /dev/null
+++ b/src/nix/modules/service/nixos.nix
@@ -0,0 +1,60 @@
+/*
+  Generic options for adding NixOS modules and evaluating the NixOS
+  configuration. The methods for installing NixOS in the image are
+  defined elsewhere.
+ */
+{ pkgs, lib, config, ... }:
+let
+  inherit (lib) types;
+  inherit (types) attrs;
+in
+{
+  options = {
+    nixos.configuration = lib.mkOption {
+      type = with types; coercedTo unspecified (a: [a]) (listOf unspecified);
+      default = {};
+      description = ''
+        Modules to add to the NixOS configuration.
+
+        This option is unused by default, because not all images use NixOS.
+
+        One way to use this is to enable <code>nixos.useSystemd</code>, but the
+        NixOS configuration can be used in other ways.
+      '';
+    };
+
+    nixos.build = lib.mkOption {
+      type = attrs;
+      readOnly = true;
+      description = ''
+        NixOS build products from <code>config.system.build</code>, such as <code>toplevel</code> and <code>etc</code>.
+
+        This option is unused by default, because not all images use NixOS.
+
+        One way to use this is to enable <code>nixos.useSystemd</code>, but the
+        NixOS configuration can be used in other ways.
+      '';
+    };
+
+    nixos.evaluatedConfig = lib.mkOption {
+      type = attrs;
+      readOnly = true;
+      description = ''
+        Evaluated NixOS configuration, to be read by service-level modules.
+
+        This option is unused by default, because not all images use NixOS.
+
+        One way to use this is to enable <code>nixos.useSystemd</code>, but the
+        NixOS configuration can be used in other ways.
+      '';
+    };
+  };
+
+  config = {
+    nixos.build = builtins.addErrorContext "while evaluating the service nixos configuration" (
+      pkgs.nixos config.nixos.configuration
+    );
+    nixos.configuration = { config, ... }: { system.build.theConfig = config; };
+    nixos.evaluatedConfig = config.nixos.build.theConfig;
+  };
+}
