diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+# 1.0.0.0
+
+Initial version
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,28 @@
+Copyright 2019 Clovyr LLC
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,32 @@
+# aeson-yaml
+
+BSD3-licensed library to encode any Aeson value as YAML, without a
+dependency on an external YAML library like `yaml` (libyaml C FFI) or
+`HsYaml` (GPL).
+
+## Usage
+
+```haskell
+import qualified Data.Aeson.Yaml as Aeson.Yaml
+
+Aeson.Yaml.encode :: ToJSON a => a -> LazyByteString
+
+-- To encode multiple values, separated by '---' (YAML documents),
+-- use `encodeDocuments`.
+Aeson.Yaml.encodeDocuments :: ToJSON a => [a] -> LazyByteString
+
+-- To encode values of different types, use `toJSON` from `Data.Aeson`
+-- like so:
+encodeDocuments [toJSON x, toJSON y, toJSON z]
+```
+
+See [bin/JsonToYaml.hs](bin/JsonToYaml.hs) for a simple command-line application
+using this library.
+
+## Documentation
+
+[Hackage](https://hackage.haskell.org/package/aeson-yaml)
+
+## License
+
+[BSD3](LICENSE)
diff --git a/aeson-yaml.cabal b/aeson-yaml.cabal
new file mode 100644
--- /dev/null
+++ b/aeson-yaml.cabal
@@ -0,0 +1,82 @@
+cabal-version: 1.12
+
+name:           aeson-yaml
+version:        1.0.0.0
+homepage:       https://github.com/clovyr/aeson-yaml
+bug-reports:    https://github.com/clovyr/aeson-yaml/issues
+author:         Patrick Nielsen
+maintainer:     patrick@clovyr.io
+copyright:      2019 Clovyr LLC
+license:        BSD3
+license-file:   LICENSE
+build-type:     Simple
+category:       Text, Web, JSON, YAML
+synopsis:       Output any Aeson value as YAML (pure Haskell library)
+description:
+    This library exposes functions for encoding any Aeson value as YAML. There
+    is also support for encoding multiple values into YAML "documents".
+    .
+    This library is pure Haskell, and does not depend on C FFI with libyaml. It
+    is also licensed under the BSD3 license.
+
+extra-source-files:
+    README.md
+    CHANGELOG.md
+
+source-repository head
+  type: git
+  location: https://github.com/clovyr/aeson-yaml
+
+flag build-binaries
+  description: Build the binaries
+  manual: True
+  default: False
+
+library
+  hs-source-dirs: src
+  exposed-modules:
+      Data.Aeson.Yaml
+  build-depends:
+      aeson >= 0.4.0.0 && < 1.5
+    , base >= 4.5.0.0 && < 4.13
+    , bytestring >= 0.10.4.0 && < 0.11
+    , text >= 0.1 && < 1.3
+    , unordered-containers >= 0.1.0.0 && < 0.3
+    , vector >= 0.1 && < 0.13
+  ghc-options:
+      -Wall
+  default-language: Haskell2010
+
+test-suite test
+  hs-source-dirs: test
+  main-is: Driver.hs
+  other-modules:
+      Test.Data.Aeson.Yaml
+  build-depends:
+      aeson
+    , aeson-yaml
+    , base
+    , bytestring
+    -- , hedgehog
+    -- , hedgehog-gen-json
+    , string-qq
+    , tasty
+    , tasty-discover
+    , tasty-hunit
+    , unordered-containers
+    , yaml
+  type: exitcode-stdio-1.0
+  default-language: Haskell2010
+
+executable json-to-yaml
+  if !flag(build-binaries)
+    buildable: False
+  hs-source-dirs: bin
+  main-is: JsonToYaml.hs
+  build-depends:
+      aeson
+    , aeson-yaml
+    , base
+    , bytestring
+  ghc-options: -Wall -threaded
+  default-language: Haskell2010
diff --git a/bin/JsonToYaml.hs b/bin/JsonToYaml.hs
new file mode 100644
--- /dev/null
+++ b/bin/JsonToYaml.hs
@@ -0,0 +1,12 @@
+module Main where
+
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Yaml as Aeson.Yaml
+import qualified Data.ByteString.Lazy as ByteString.Lazy
+
+main :: IO ()
+main =
+  ByteString.Lazy.interact $ \s ->
+    case Aeson.eitherDecode' s of
+      Left err -> error ("Failed to decode JSON: " <> err)
+      Right v -> Aeson.Yaml.encode (v :: Aeson.Value)
diff --git a/src/Data/Aeson/Yaml.hs b/src/Data/Aeson/Yaml.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Aeson/Yaml.hs
@@ -0,0 +1,91 @@
+{-|
+This library exposes functions for encoding any Aeson value as YAML.
+There is also support for encoding multiple values into YAML
+"documents".
+
+This library is pure Haskell, and does not depend on C FFI with
+libyaml. It is also licensed under the BSD3 license.
+
+This module is meant to be imported qualified.
+-}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module Data.Aeson.Yaml
+  ( encode
+  , encodeDocuments
+  ) where
+
+import Data.Aeson hiding (encode)
+import qualified Data.Aeson
+import Data.ByteString (ByteString)
+import Data.ByteString.Builder (Builder)
+import qualified Data.ByteString.Builder as ByteString.Builder
+import qualified Data.ByteString.Lazy as ByteString.Lazy
+import qualified Data.ByteString.Short as ByteString.Short
+import qualified Data.HashMap.Strict as HashMap
+import Data.List (sortOn)
+import Data.List (intersperse)
+import Data.Monoid ((<>), mconcat, mempty)
+import qualified Data.Text.Encoding as Text.Encoding
+import qualified Data.Vector as Vector
+
+b :: ByteString -> Builder
+b = ByteString.Builder.byteString
+
+bl :: ByteString.Lazy.ByteString -> Builder
+bl = ByteString.Builder.lazyByteString
+
+bs :: ByteString.Short.ShortByteString -> Builder
+bs = ByteString.Builder.shortByteString
+
+indent :: Int -> Builder
+indent 0 = mempty
+indent n = bs "  " <> (indent $! n - 1)
+
+enc :: ToJSON a => a -> ByteString.Lazy.ByteString
+enc = Data.Aeson.encode
+
+-- | Encode a value as YAML (lazy bytestring).
+encode :: ToJSON a => a -> ByteString.Lazy.ByteString
+encode = ByteString.Builder.toLazyByteString . encodeBuilder False 0 . toJSON
+
+-- | Encode multiple values separated by '---'. To encode values of different
+-- types, @import Data.Aeson(ToJSON(toJSON))@ and do
+-- @encodeDocuments [toJSON x, toJSON y, toJSON z]@.
+encodeDocuments :: ToJSON a => [a] -> ByteString.Lazy.ByteString
+encodeDocuments =
+  ByteString.Builder.toLazyByteString .
+  mconcat . intersperse (bs "\n---\n") . map ((encodeBuilder False 0) . toJSON)
+
+encodeBuilder :: Bool -> Int -> Data.Aeson.Value -> Builder
+encodeBuilder newlineBeforeObject level value =
+  case value of
+    Object hm ->
+      mconcat $
+      (if newlineBeforeObject
+         then (prefix :)
+         else id) $
+      intersperse prefix $ map (keyValue level) (sortOn fst $ HashMap.toList hm)
+      where prefix = bs "\n" <> indent level
+    Array vec ->
+      mconcat $
+      (prefix :) $
+      intersperse prefix $
+      map (encodeBuilder False (level + 1)) (Vector.toList vec)
+      where prefix = bs "\n" <> indent level <> bs "- "
+    String s -> bl (enc s)
+    Number n -> bl (enc n)
+    Bool bool -> bl (enc bool)
+    Null -> bs "null"
+  where
+    keyValue level' (k, v) =
+      mconcat
+        [ b (Text.Encoding.encodeUtf8 k)
+        , ":"
+        , case v of
+            Object _ -> ""
+            Array _ -> ""
+            _ -> " "
+        , encodeBuilder True (level' + 1) v
+        ]
diff --git a/test/Driver.hs b/test/Driver.hs
new file mode 100644
--- /dev/null
+++ b/test/Driver.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF tasty-discover #-}
diff --git a/test/Test/Data/Aeson/Yaml.hs b/test/Test/Data/Aeson/Yaml.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Data/Aeson/Yaml.hs
@@ -0,0 +1,166 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+module Test.Data.Aeson.Yaml where
+
+import qualified Data.Aeson
+import qualified Data.ByteString.Lazy
+import Data.Either (fromRight)
+import qualified Data.HashMap.Strict as HashMap
+import Data.String.QQ (s)
+import qualified Data.Yaml
+
+-- import Hedgehog
+-- import Hedgehog.Gen
+-- import Hedgehog.Gen.JSON (genJSONValue, sensibleRanges)
+-- import Hedgehog.Range
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertEqual, testCase)
+
+import qualified Data.Aeson.Yaml
+
+data TestCase =
+  TestCase
+    { tcName :: String
+    , tcInput :: Data.ByteString.Lazy.ByteString
+    , tcOutput :: Data.ByteString.Lazy.ByteString
+    }
+
+testCases :: [TestCase]
+testCases =
+  [ TestCase
+      { tcName = "Nested lists and objects"
+      , tcInput =
+          [s|
+{
+   "apiVersion": "apps/v1",
+   "kind": "Deployment",
+   "metadata": {
+      "labels": {
+         "app": "foo"
+      },
+      "name": "{{ .Release.Name }}-deployment"
+   },
+   "spec": {
+      "replicas": 1,
+      "selector": {
+         "matchLabels": {
+            "app": "foo"
+         }
+      },
+      "template": {
+         "metadata": {
+            "labels": {
+               "app": "foo"
+            },
+            "name": "{{ .Release.Name }}-pod"
+         },
+         "spec": {
+            "containers": [
+               {
+                  "command": [
+                     "/data/bin/foo",
+                     "--port=7654"
+                  ],
+                  "image": "ubuntu:latest",
+                  "name": "{{ .Release.Name }}-container",
+                  "ports": [
+                     {
+                        "containerPort": 7654
+                     }
+                  ],
+                  "volumeMounts": [
+                     {
+                        "mountPath": "/data/mount1",
+                        "name": "{{ .Release.Name }}-volume-mount1"
+                     },
+                     {
+                        "mountPath": "/data/mount2",
+                        "name": "{{ .Release.Name }}-volume-mount2"
+                     }
+                  ]
+               }
+            ]
+         }
+      }
+   }
+}
+|]
+      , tcOutput =
+          [s|apiVersion: "apps/v1"
+kind: "Deployment"
+metadata:
+  labels:
+    app: "foo"
+  name: "{{ .Release.Name }}-deployment"
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: "foo"
+  template:
+    metadata:
+      labels:
+        app: "foo"
+      name: "{{ .Release.Name }}-pod"
+    spec:
+      containers:
+        - command:
+            - "/data/bin/foo"
+            - "--port=7654"
+          image: "ubuntu:latest"
+          name: "{{ .Release.Name }}-container"
+          ports:
+            - containerPort: 7654
+          volumeMounts:
+            - mountPath: "/data/mount1"
+              name: "{{ .Release.Name }}-volume-mount1"
+            - mountPath: "/data/mount2"
+              name: "{{ .Release.Name }}-volume-mount2"|]
+      }
+  ]
+
+foo :: Data.Aeson.Value
+foo = Data.Aeson.Object $ HashMap.fromList [("foo", "bar")]
+
+test_testCases :: TestTree
+test_testCases = testGroup "Test Cases" $ map mkTestCase testCases
+  where
+    mkTestCase TestCase {..} =
+      testCase tcName $ do
+        assertEqual "Expected output" tcOutput output
+        assertEqual
+          "libyaml decodes the original value"
+          decodedInput
+          decodedYaml
+        assertEqual
+          "Expected documents output"
+          ("foo: \"bar\"" <> "\n---\n" <> output)
+          (Data.Aeson.Yaml.encodeDocuments [foo, decodedInput])
+      where
+        output = Data.Aeson.Yaml.encode decodedInput
+        decodedInput :: Data.Aeson.Value
+        decodedInput =
+          fromRight (error "couldn't decode JSON") $
+          Data.Aeson.eitherDecode' tcInput
+        decodedYaml :: Data.Aeson.Value
+        decodedYaml =
+          fromRight (error "couldn't decode YAML") $
+          Data.Yaml.decodeEither' (Data.ByteString.Lazy.toStrict output)
+-- TODO: SBV dep of hedgehog-gen-json doesn't currently build
+-- hprop_decodesSameAsLibyaml :: Property
+-- hprop_decodesSameAsLibyaml =
+--   property $ do
+--     v <- forAll $ genJSONValue sensibleRanges
+--     let enc1 = Data.Aeson.Yaml.encode v
+--         enc2 = Data.Yaml.encode v
+--         dec1 =
+--           fromRight
+--             (error "couldn't decode Aeson.Yaml-encoded value")
+--             (Data.Yaml.decodeEither' enc1)
+--         dec2 =
+--           fromRight
+--             (error "couldn't decode Data.Yaml-encoded value")
+--             (Data.Yaml.decodeEither' enc2)
+--     dec1 === dec2
