Repository: featureform/featureform (language: python)

Issue:
Featureform in a single container
# Description

<!--- Please include a summary of the changes and the related issue. -->
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
This pull request adds featureform into a single container using nginx and supervisord. 
The goal is to decrease the time to run featureform locally


## Type of change

### Does this correspond to an open issue?
<!--- Provide a link to the issue if not already associated -->
No

### Select type(s) of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update

# Checklist:

- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have fixed any merge conflicts



Discussion / hints:

Fix patch:
diff --git a/.readme_mlc_config.json b/.readme_mlc_config.json
index f799fb9db6..47594fc2a5 100644
--- a/.readme_mlc_config.json
+++ b/.readme_mlc_config.json
@@ -2,6 +2,9 @@
   "ignorePatterns": [
     {
       "pattern": "^http://localhost:3000"
+    },
+    {
+      "pattern": "^http://localhost"
     }
   ],
   "replacementPatterns": [
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000..20baf25eef
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,66 @@
+FROM node:16-alpine
+COPY ./dashboard ./dashboard
+WORKDIR ./dashboard
+RUN npm install --legacy-peer-deps
+RUN npm run build
+RUN rm -r node_modules
+
+FROM golang:1.17
+
+WORKDIR /app
+
+COPY --from=0 ./dashboard ./dashboard
+
+RUN apt-get update && apt-get install -y supervisor
+RUN mkdir -p /var/lock/apache2 /var/run/apache2 /var/run/sshd /var/log/supervisor
+
+COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
+COPY go.mod ./
+COPY go.sum ./
+COPY api/ api/
+COPY helpers/ helpers/
+COPY metadata/ metadata/
+COPY metrics/ metrics/
+COPY proto/ proto/
+COPY coordinator/ coordinator/
+COPY provider/ provider/
+COPY runner/ runner/
+COPY newserving/ newserving/
+COPY nginx.conf/ /etc/nginx/nginx.conf
+
+RUN apt install protobuf-compiler -y
+RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
+RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
+RUN protoc --go_out=. --go_opt=paths=source_relative     --go-grpc_out=. --go-grpc_opt=paths=source_relative     ./proto/serving.proto
+RUN protoc --go_out=. --go_opt=paths=source_relative     --go-grpc_out=. --go-grpc_opt=paths=source_relative     ./metadata/proto/metadata.proto
+
+RUN mkdir execs
+RUN go build api/main.go
+RUN mv main execs/api
+RUN go build metadata/server/server.go
+RUN mv server execs/metadata
+RUN go build coordinator/main/main.go
+RUN mv main execs/coordinator
+RUN go build metadata/dashboard/dashboard_metadata.go
+RUN mv dashboard_metadata execs/dashboard_metadata
+RUN go build newserving/main/main.go
+RUN mv main execs/serving
+
+RUN git clone -b v3.4.16 https://github.com/etcd-io/etcd.git
+WORKDIR /app/etcd
+RUN ./build
+WORKDIR /app
+RUN ETCD_UNSUPPORTED_ARCH=arm64 ./etcd/bin/etcd --version
+
+RUN apt-get update
+RUN apt-get install -y nginx --option=Dpkg::Options::=--force-confdef
+
+WORKDIR /app
+
+ENV ENABLE_TYPESENSE="false"
+ENV SERVING_PORT="8082"
+ENV SERVING_HOST="0.0.0.0"
+ENV ETCD_ARCH=""
+
+EXPOSE 7878
+CMD ["/usr/bin/supervisord"]
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 0fddff5c9f..488ab1c006 100644
--- a/Makefile
+++ b/Makefile
@@ -205,6 +205,7 @@ stop_postgres:
 pytest:
 	-rm -r .featureform
 	curl -C - https://featureform-demo-files.s3.amazonaws.com/transactions.csv -o transactions.csv
+	pytest client/tests/test_cli.py
 	pytest client/tests/provider_config_test.py
 	pytest client/tests/serving_test.py
 	pytest client/src/featureform/local_dash_test.py
diff --git a/api/main.go b/api/main.go
index 1e421d00d3..85d96f421c 100644
--- a/api/main.go
+++ b/api/main.go
@@ -637,7 +637,7 @@ func (serv *OnlineServer) TrainingData(req *srv.TrainingDataRequest, stream srv.
 	serv.Logger.Infow("Serving Training Data", "id", req.Id.String())
 	client, err := serv.client.TrainingData(context.Background(), req)
 	if err != nil {
-		return fmt.Errorf("training data: %w", err)
+		return fmt.Errorf("could not serve training data: %w", err)
 	}
 	for {
 		row, err := client.Recv()
diff --git a/charts/featureform/charts/feature-server/templates/deployment.yaml b/charts/featureform/charts/feature-server/templates/deployment.yaml
index a6fe5532c0..3aaaf63007 100644
--- a/charts/featureform/charts/feature-server/templates/deployment.yaml
+++ b/charts/featureform/charts/feature-server/templates/deployment.yaml
@@ -29,7 +29,7 @@ spec:
             - containerPort: {{ .Values.metrics.port }}
           env:
             - name: SERVING_PORT
-              value: 0.0.0.0:{{ .Values.serving.port }}
+              value: {{ .Values.serving.port | quote }}
             - name: METRICS_PORT
               value: 0.0.0.0:{{ .Values.metrics.port }}
             - name: METADATA_HOST
diff --git a/client/setup.cfg b/client/setup.cfg
index 86a1dfb122..243bed936d 100644
--- a/client/setup.cfg
+++ b/client/setup.cfg
@@ -34,6 +34,7 @@ install_requires =
     flask==2.2.1
     Flask-Cors==3.0.10
     pyspark>=3.3.0
+    validators>=0.20.0
 
 [options.packages.find]
 where = src
diff --git a/client/src/featureform/cli.py b/client/src/featureform/cli.py
index b40e8f7312..ffcfe6eedb 100644
--- a/client/src/featureform/cli.py
+++ b/client/src/featureform/cli.py
@@ -9,6 +9,8 @@
 import os
 from flask import Flask
 from .dashboard_metadata import dashboard_app
+import validators
+import urllib.request
 
 resource_types = [
     "feature",
@@ -148,7 +150,7 @@ def dash():
     app.run(threaded=True, port=os.getenv("LOCALMODE_DASHBOARD_PORT", 3000))
 
 @cli.command()
-@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
+@click.argument("files", required=True, nargs=-1)
 @click.option("--host",
               "host",
               required=False,
@@ -163,12 +165,24 @@ def dash():
 @click.option("--local",
               is_flag=True,
               help="Enable local mode")
-def apply(host, cert, insecure, local, files):
+@click.option("--dry-run",
+              is_flag=True,
+              help="Checks the definitions without applying them")
+def apply(host, cert, insecure, local, files, dry_run):
     for file in files:
-        with open(file, "r") as py:
-            exec(py.read())
-
-    rc = ResourceClient(host=host, local=local, insecure=insecure, cert_path=cert)
+        if os.path.isfile(file):
+            with open(file, "r") as py:
+                exec(py.read())
+        elif validators.url(file):
+            try:
+                with urllib.request.urlopen(file) as py:
+                    exec(py.read())
+            except Exception as e:
+                raise ValueError(f"Could not apply the provided URL: {e}: {file}")
+        else:
+            raise ValueError(f"Argument must be a path to a file or URL with a valid schema (http:// or https://): {file}")
+
+    rc = ResourceClient(host=host, local=local, insecure=insecure, cert_path=cert, dry_run=dry_run)
     rc.apply()
 
 
diff --git a/client/src/featureform/register.py b/client/src/featureform/register.py
index 328ce58a8c..34c9ed086f 100644
--- a/client/src/featureform/register.py
+++ b/client/src/featureform/register.py
@@ -1796,7 +1796,7 @@ class ResourceClient(Registrar):
     ```
     """
 
-    def __init__(self, host=None, local=False, insecure=False, cert_path=None):
+    def __init__(self, host=None, local=False, insecure=False, cert_path=None, dry_run=False):
         """Initialise a Resource Client object.
 
         Args:
@@ -1806,8 +1806,13 @@ def __init__(self, host=None, local=False, insecure=False, cert_path=None):
             cert_path (str): The path to a public certificate if using a self-signed certificate.
         """
         super().__init__()
+        self._dry_run = dry_run
         self._stub = None
         self.local = local
+
+        if dry_run:
+            return
+
         if local and host:
             raise ValueError("Cannot be local and have a host")
         elif not local:
@@ -1827,6 +1832,10 @@ def apply(self):
         """Apply all definitions, creating and retrieving all specified resources.
         """
 
+        if self._dry_run:
+            print(state().sorted_list())
+            return
+
         if self.local:
             state().create_all_local()
         else:
diff --git a/client/src/featureform/resources.py b/client/src/featureform/resources.py
index ffbfc01f75..7d5389441e 100644
--- a/client/src/featureform/resources.py
+++ b/client/src/featureform/resources.py
@@ -15,12 +15,14 @@
 
 NameVariant = Tuple[str, str]
 
+
 @typechecked
 @dataclass
 class OperationType(Enum):
     GET = 0
     CREATE = 1
 
+
 @typechecked
 @dataclass
 class SourceType(Enum):
@@ -28,10 +30,12 @@ class SourceType(Enum):
     DF_TRANSFORMATION = "DF"
     SQL_TRANSFORMATION = "SQL"
 
+
 @typechecked
 def valid_name_variant(nvar: NameVariant) -> bool:
     return nvar[0] != "" and nvar[1] != ""
 
+
 @typechecked
 @dataclass
 class Schedule:
@@ -48,9 +52,12 @@ def type(self) -> str:
         return "schedule"
 
     def _create(self, stub) -> None:
-        serialized = pb.SetScheduleChangeRequest(resource=pb.ResourceId(pb.NameVariant(name=self.name, variant=self.variant), resource_type=self.resource_type), schedule=self.schedule_string)
+        serialized = pb.SetScheduleChangeRequest(
+            resource=pb.ResourceId(pb.NameVariant(name=self.name, variant=self.variant),
+                                   resource_type=self.resource_type), schedule=self.schedule_string)
         stub.RequestScheduleChange(serialized)
 
+
 @typechecked
 @dataclass
 class RedisConfig:
@@ -73,6 +80,7 @@ def serialize(self) -> bytes:
         }
         return bytes(json.dumps(config), "utf-8")
 
+
 @typechecked
 @dataclass
 class FirestoreConfig:
@@ -94,6 +102,7 @@ def serialize(self) -> bytes:
         }
         return bytes(json.dumps(config), "utf-8")
 
+
 @typechecked
 @dataclass
 class CassandraConfig:
@@ -122,6 +131,7 @@ def serialize(self) -> bytes:
         }
         return bytes(json.dumps(config), "utf-8")
 
+
 @typechecked
 @dataclass
 class DynamodbConfig:
@@ -143,6 +153,7 @@ def serialize(self) -> bytes:
         }
         return bytes(json.dumps(config), "utf-8")
 
+
 @typechecked
 @dataclass
 class LocalConfig:
@@ -157,7 +168,8 @@ def serialize(self) -> bytes:
         config = {
         }
         return bytes(json.dumps(config), "utf-8")
-        
+
+
 @typechecked
 @dataclass
 class SnowflakeConfig:
@@ -258,6 +270,7 @@ def serialize(self) -> bytes:
         }
         return bytes(json.dumps(config), "utf-8")
 
+
 @typechecked
 @dataclass
 class SparkAWSConfig:
@@ -279,21 +292,24 @@ def serialize(self) -> bytes:
             "ExecutorType": "EMR",
             "StoreType": "S3",
             "ExecutorConfig": {
-                    "AWSAccessKeyId": self.aws_access_key_id,
-                    "AWSSecretKey":   self.aws_secret_access_key,
-                    "ClusterRegion":  self.emr_cluster_region,
-                    "ClusterName":    self.emr_cluster_id,
-               },
+                "AWSAccessKeyId": self.aws_access_key_id,
+                "AWSSecretKey": self.aws_secret_access_key,
+                "ClusterRegion": self.emr_cluster_region,
+                "ClusterName": self.emr_cluster_id,
+            },
             "StoreConfig": {
-                    "AWSAccessKeyId": self.aws_access_key_id,
-                    "AWSSecretKey":   self.aws_secret_access_key,
-                    "BucketRegion":   self.bucket_region,
-                    "BucketPath":     self.bucket_path,
-                }
+                "AWSAccessKeyId": self.aws_access_key_id,
+                "AWSSecretKey": self.aws_secret_access_key,
+                "BucketRegion": self.bucket_region,
+                "BucketPath": self.bucket_path,
             }
+        }
         return bytes(json.dumps(config), "utf-8")
 
-Config = Union[RedisConfig, SnowflakeConfig, PostgresConfig, RedshiftConfig, LocalConfig, BigQueryConfig, FirestoreConfig, SparkAWSConfig]
+
+Config = Union[
+    RedisConfig, SnowflakeConfig, PostgresConfig, RedshiftConfig, LocalConfig, BigQueryConfig, FirestoreConfig, SparkAWSConfig]
+
 
 @typechecked
 @dataclass
@@ -346,7 +362,6 @@ def __eq__(self, other):
         return True
 
 
-
 @typechecked
 @dataclass
 class User:
@@ -588,7 +603,7 @@ class Feature:
     variant: str = "default"
     schedule: str = ""
     schedule_obj: Schedule = None
-    
+
     def update_schedule(self, schedule) -> None:
         self.schedule_obj = Schedule(name=self.name, variant=self.variant, resource_type=4, schedule_string=schedule)
         self.schedule = schedule
@@ -723,6 +738,7 @@ def __eq__(self, other):
                 return False
         return True
 
+
 @typechecked
 @dataclass
 class EntityReference:
@@ -744,13 +760,14 @@ def _get(self, stub):
                 self.obj = entity
         except grpc._channel._MultiThreadedRendezvous:
             raise ValueError(f"Entity {self.name} not found.")
-    
+
     def _get_local(self, db):
         local_entity = db.query_resource("entities", "name", self.name)
         if local_entity == []:
             raise ValueError(f"Entity {self.name} not found.")
         self.obj = local_entity
 
+
 @typechecked
 @dataclass
 class ProviderReference:
@@ -773,13 +790,14 @@ def _get(self, stub):
                 self.obj = provider
         except grpc._channel._MultiThreadedRendezvous:
             raise ValueError(f"Provider {self.name} of type {self.provider_type} not found.")
-        
+
     def _get_local(self, db):
         local_provider = db.query_resource("providers", "name", self.name)
         if local_provider == []:
             raise ValueError("Local mode provider not found.")
         self.obj = local_provider
 
+
 @typechecked
 @dataclass
 class SourceReference:
@@ -802,13 +820,14 @@ def _get(self, stub):
                 self.obj = source
         except grpc._channel._MultiThreadedRendezvous:
             raise ValueError(f"Source {self.name}, variant {self.variant} not found.")
-    
+
     def _get_local(self, db):
         local_source = db.get_source_variant(self.name, self.variant)
         if local_source == []:
             raise ValueError(f"Source {self.name}, variant {self.variant} not found.")
         self.obj = local_source
 
+
 @typechecked
 @dataclass
 class TrainingSet:
@@ -857,7 +876,7 @@ def _create(self, stub) -> None:
         stub.CreateTrainingSetVariant(serialized)
 
     def _create_local(self, db) -> None:
-        self._check_insert_training_set_resources(db)   
+        self._check_insert_training_set_resources(db)
         db.insert("training_set_variant",
                   str(time.time()),
                   self.description,
@@ -893,7 +912,7 @@ def _check_insert_training_set_resources(self, db) -> None:
                 self.name,
                 self.variant,
                 feature_name,  # feature name
-                feature_variant # feature variant
+                feature_variant  # feature variant
             )
 
     def __eq__(self, other):
@@ -939,8 +958,8 @@ def add(self, resource: Resource) -> None:
         self.__create_list.append(resource)
         if hasattr(resource, 'schedule_obj') and resource.schedule_obj != None:
             my_schedule = resource.schedule_obj
-            key = (my_schedule.type(),  my_schedule.name)
-            self.__state[key] =  my_schedule
+            key = (my_schedule.type(), my_schedule.name)
+            self.__state[key] = my_schedule
             self.__create_list.append(my_schedule)
 
     def sorted_list(self) -> List[Resource]:
@@ -962,6 +981,13 @@ def to_sort_key(res):
 
         return sorted(self.__state.values(), key=to_sort_key)
 
+    def create_all_dryrun(self) -> None:
+        for resource in self.__create_list:
+            if resource.operation_type() is OperationType.GET:
+                print("Getting", resource.type(), resource.name)
+            if resource.operation_type() is OperationType.CREATE:
+                print("Creating", resource.type(), resource.name)
+
     def create_all_local(self) -> None:
         db = SQLiteMetadata()
         for resource in self.__create_list:
diff --git a/coordinator/main/main.go b/coordinator/main/main.go
index c3cbf6a251..7fe3127404 100644
--- a/coordinator/main/main.go
+++ b/coordinator/main/main.go
@@ -13,7 +13,7 @@ import (
 
 func main() {
 	etcdHost := help.GetEnv("ETCD_HOST", "localhost")
-	etcdPort := help.GetEnv("ETCD_PORT", "6379")
+	etcdPort := help.GetEnv("ETCD_PORT", "2379")
 	etcdUrl := fmt.Sprintf("%s:%s", etcdHost, etcdPort)
 	metadataHost := help.GetEnv("METADATA_HOST", "localhost")
 	metadataPort := help.GetEnv("METADATA_PORT", "8080")
diff --git a/docs/getting-started/interact-with-the-cli.md b/docs/getting-started/interact-with-the-cli.md
index 7990162552..825d400452 100644
--- a/docs/getting-started/interact-with-the-cli.md
+++ b/docs/getting-started/interact-with-the-cli.md
@@ -6,6 +6,8 @@ The Featureform CLI allows you to describe, list, and monitor your resources.
 
 The **apply** command submits resource definitions to the Featureform instance.&#x20;
 
+The argument can either be a path to a local file or the url of a hosted file. Multiple files can be included at a time.
+
 ```
 featureform apply --host $FEATUREFORM_HOST --cert $FEATUREFORM_CERT <definitions.py>
 ```
diff --git a/docs/quickstart-docker.md b/docs/quickstart-docker.md
new file mode 100644
index 0000000000..5059ec651d
--- /dev/null
+++ b/docs/quickstart-docker.md
@@ -0,0 +1,196 @@
+---
+description: A quick start guide for Featureform with Docker.
+---
+
+# Quickstart (Docker)
+This quickstart uses the standalone containered version of Featureform. It can be used to connect to the same providers 
+as the Kubernetes Hosted version, but lacks the scaling capabilities.
+
+This quickstart will walk through creating a few simple features, labels, and a training set from a fraud
+detection dataset using Postgres and Redis.
+
+### Requirements
+
+- Python 3.7+
+- Docker
+
+## Step 1: Install The Featureform CLI
+```shell
+pip install featureform
+```
+
+## Step 2: Pull Containers
+We'll use Postgres and Redis as providers in this quickstart. We've created a Featureform Postgres container that
+has preloaded data. You can checkout the source data [here](https://featureform-demo-files.s3.amazonaws.com/transactions.csv), 
+we'll just be using a sample of it.
+
+We'll also run the Featureform container in the foreground so logs are available.
+
+```shell
+docker run -d -p 5432:5432 featureformcom/postgres
+docker run -d -p 6379:6379 redis
+docker run -p 7878:7878 -p 80:80 featureformcom/featureform
+```
+
+Note: If using an M1 Mac, run the following command instead for compatibility 
+```shell
+docker run -p 7878:7878 -p 80:80 -e ETCD_ARCH="ETCD_UNSUPPORTED_ARCH=arm64" featureformcom/featureform
+```
+
+Wait 30-60 seconds to allow the Postgres container to fully initialize the sample data. 
+
+## Step 3: Set Environment Variables
+We can store the address of our container endpoint for applying definitions and serving later. 
+
+```shell
+export FEATUREFORM_HOST=localhost:7878 
+```
+
+## Step 4: Apply Definitions
+Featureform definitions can be stored as both a local file and URL. Multiple files can be applied at the same time. 
+
+We'll set the `--insecure` flag since we're using an unencrypted endpoint on the container.
+
+```shell
+featureform apply https://featureform-demo-files.s3.amazonaws.com/definitions.py --insecure
+```
+
+## Step 5: Dashboard and Serving
+The dashboard is available at [localhost](http://localhost)
+
+In the dashboard you should be able to see that 2 Sources, 1 Feature, 1 Label, and 1 Training Set has been created. 
+
+You can check also check the status of the training set with:
+
+```shell
+featureform get training-set fraud_training default --insecure
+```
+
+When the status of these resources is READY, you can serve them with:
+```shell
+curl https://featureform-demo-files.s3.amazonaws.com/serving.py -o serving.py && python serving.py
+curl https://featureform-demo-files.s3.amazonaws.com/training.py -o training.py && python training.py
+```
+
+This will download and run sample serving and training scripts, which will serve a single feature value and a sample 
+of a training data set. 
+
+# How Does It Work?
+Now that we have everything running, we'll walk through what was done to create the training set and feature.
+
+## Apply
+If we download the definitions.py file, we can see what Featureform is doing when we run `featureform apply`.
+
+First we register the Postgres and Redis containers as providers so Featureform is aware of them.
+{% code title="definitions.py" %}
+```python
+import featureform as ff
+
+postgres = ff.register_postgres(
+    name="postgres-quickstart",
+    host="host.docker.internal",  # The docker dns name for postgres
+    port="5432",
+    user="postgres",
+    password="password",
+    database="postgres",
+)
+
+redis = ff.register_redis(
+    name = "redis-quickstart",
+    host="host.docker.internal", # The docker dns name for redis
+    port=6379,
+)
+```
+{% endcode %}
+
+We can then register our sources. 
+
+The first source we'll register is our Transactions table that exists in Postgres. This is so Featureform is aware that the
+Transactions table exists and can be used as a dependency. 
+
+We can then create a Transformation source off of our Transactions table. This is done using an SQL query that is 
+executed in Postgres and saved in a table.
+{% code title="definitions.py" %}
+```python
+transactions = postgres.register_table(
+    name="transactions",
+    table="Transactions",  # This is the table's name in Postgres
+)
+
+@postgres.sql_transformation()
+def average_user_transaction():
+    return "SELECT CustomerID as user_id, avg(TransactionAmount) " \
+           "as avg_transaction_amt from {{transactions.default}} GROUP BY user_id"
+```
+{% endcode %}
+
+We can then register our feature, label, and training set.
+
+The feature is registered off of the table we created with our SQL Transformation. 
+
+The label is registered off of our base Transactions table.
+
+A Training Set can be created by joining our feature and label together.
+
+{% code title="definitions.py" %}
+```python
+user = ff.register_entity("user")
+# Register a column from our transformation as a feature
+average_user_transaction.register_resources(
+    entity=user,
+    entity_column="user_id",
+    inference_store=redis,
+    features=[
+        {"name": "avg_transactions", "column": "avg_transaction_amt", "type": "float32"},
+    ],
+)
+# Register label from our base Transactions table
+transactions.register_resources(
+    entity=user,
+    entity_column="customerid",
+    labels=[
+        {"name": "fraudulent", "column": "isfraud", "type": "bool"},
+    ],
+)
+
+ff.register_training_set(
+    "fraud_training",
+    label="fraudulent",
+    features=["avg_transactions"],
+)
+```
+{% endcode %}
+
+## Serving
+
+We can serve single features from Redis with the Serving Client. The `features()` method takes the name of the feature
+and an entity that we want the value for.
+{% code title="serving.py" %}
+```python
+from featureform import ServingClient
+
+serving = ServingClient(insecure=True)
+
+user_feat = serving.features(["avg_transactions"], {"user": "C1214240"})
+print("User Result: ")
+print(user_feat)
+```
+{% endcode %}
+
+## Training
+
+We can serve a training dataset from Postgres with the Serving Client as well. This example takes the name of the 
+training set and returns 25 rows of the training set. 
+{% code title="training.py" %}
+```python
+from featureform import ServingClient
+
+client = ServingClient(insecure=True)
+dataset = client.training_set("fraud_training")
+
+for i, batch in enumerate(dataset):
+    print(batch)
+    if i > 25:
+        break
+```
+{% endcode %}
\ No newline at end of file
diff --git a/docs/quickstart_scripts/Dockerfile b/docs/quickstart_scripts/Dockerfile
new file mode 100644
index 0000000000..119b026437
--- /dev/null
+++ b/docs/quickstart_scripts/Dockerfile
@@ -0,0 +1,5 @@
+FROM postgres
+
+ENV POSTGRES_PASSWORD=password
+
+COPY dump_transactions.sql /docker-entrypoint-initdb.d/
\ No newline at end of file
diff --git a/docs/quickstart_scripts/README.md b/docs/quickstart_scripts/README.md
new file mode 100644
index 0000000000..f5037e28ae
--- /dev/null
+++ b/docs/quickstart_scripts/README.md
@@ -0,0 +1,10 @@
+## Directory Contents
+- definitions.py - The definitions file for the Docker Container Quickstart
+- training.py - The training file for the Docker Container Quickstart
+- serving.py - The serving file for the Docker Container Quickstart
+- Dockerfile - The dockerfile to generate featureformcom/postgres
+
+## Not Included
+- dump_transactions.sql - Required for the Dockerfile. Its stored at https://featureform-demo-files.s3.amazonaws.com/dump_transactions.sql
+
+All files are stored in the s3://featureform-demo-files/ bucket.
\ No newline at end of file
diff --git a/docs/quickstart_scripts/definitions.py b/docs/quickstart_scripts/definitions.py
new file mode 100644
index 0000000000..a04076cc85
--- /dev/null
+++ b/docs/quickstart_scripts/definitions.py
@@ -0,0 +1,53 @@
+import featureform as ff
+
+postgres = ff.register_postgres(
+    name="postgres-quickstart",
+    host="host.docker.internal",  # The docker dns name for postgres
+    port="5432",
+    user="postgres",
+    password="password",
+    database="postgres",
+)
+
+redis = ff.register_redis(
+    name="redis-quickstart",
+    host="host.docker.internal",  # The docker dns name for redis
+    port=6379,
+)
+
+transactions = postgres.register_table(
+    name="transactions",
+    table="Transactions",  # This is the table's name in Postgres
+)
+
+
+@postgres.sql_transformation()
+def average_user_transaction():
+    return "SELECT CustomerID as user_id, avg(TransactionAmount) " \
+           "as avg_transaction_amt from {{transactions.default}} GROUP BY user_id"
+
+
+user = ff.register_entity("user")
+# Register a column from our transformation as a feature
+average_user_transaction.register_resources(
+    entity=user,
+    entity_column="user_id",
+    inference_store=redis,
+    features=[
+        {"name": "avg_transactions", "column": "avg_transaction_amt", "type": "float32"},
+    ],
+)
+# Register label from our base Transactions table
+transactions.register_resources(
+    entity=user,
+    entity_column="customerid",
+    labels=[
+        {"name": "fraudulent", "column": "isfraud", "type": "bool"},
+    ],
+)
+
+ff.register_training_set(
+    "fraud_training",
+    label="fraudulent",
+    features=["avg_transactions"],
+)
diff --git a/docs/quickstart_scripts/serving.py b/docs/quickstart_scripts/serving.py
new file mode 100644
index 0000000000..e36a809051
--- /dev/null
+++ b/docs/quickstart_scripts/serving.py
@@ -0,0 +1,7 @@
+from featureform import ServingClient
+
+serving = ServingClient(insecure=True)
+
+user_feat = serving.features(["avg_transactions"], {"user": "C1214240"})
+print("User Result: ")
+print(user_feat)
diff --git a/docs/quickstart_scripts/training.py b/docs/quickstart_scripts/training.py
new file mode 100644
index 0000000000..14c3a45cdd
--- /dev/null
+++ b/docs/quickstart_scripts/training.py
@@ -0,0 +1,9 @@
+from featureform import ServingClient
+
+client = ServingClient(insecure=True)
+dataset = client.training_set("fraud_training")
+
+for i, batch in enumerate(dataset):
+    print(batch)
+    if i > 25:
+        break
\ No newline at end of file
diff --git a/docs/table_of_contents.md b/docs/table_of_contents.md
index 1ca96df2b4..5c349c7996 100644
--- a/docs/table_of_contents.md
+++ b/docs/table_of_contents.md
@@ -2,6 +2,7 @@
 
 * [What is Featureform](overview.md)
 * [Quickstart (Local)](quickstart-local.md)
+* [Quickstart (Docker)](quickstart-docker.md)
 * [Quickstart (Kubernetes)](quickstart-kubernetes.md)
 * [Quickstart (Google Cloud)](quickstart-gcp.md)
 
diff --git a/go.mod b/go.mod
index 51bc432319..e3e1080026 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@ require (
 	github.com/joho/godotenv v1.4.0
 	github.com/lib/pq v1.10.4
 	github.com/mrz1836/go-sanitize v1.1.5
+	github.com/pkg/errors v0.9.1
 	github.com/prometheus/client_golang v1.12.1
 	github.com/prometheus/client_model v0.2.0
 	github.com/snowflakedb/gosnowflake v1.6.8
diff --git a/metadata/dashboard/dashboard_metadata.go b/metadata/dashboard/dashboard_metadata.go
index 5a58ed4fc2..20e8f362dc 100644
--- a/metadata/dashboard/dashboard_metadata.go
+++ b/metadata/dashboard/dashboard_metadata.go
@@ -7,8 +7,8 @@ package main
 import (
 	"context"
 	"fmt"
+	help "github.com/featureform/helpers"
 	"net/http"
-	"os"
 	"reflect"
 	"time"
 
@@ -956,12 +956,12 @@ func (m *MetadataServer) Start(port string) {
 
 func main() {
 	logger := zap.NewExample().Sugar()
-	metadataHost := os.Getenv("METADATA_HOST")
-	metadataPort := os.Getenv("METADATA_PORT")
-	typesenseHost := os.Getenv("TYPESENSE_HOST")
-	typesensePort := os.Getenv("TYPESENSE_PORT")
+	metadataHost := help.GetEnv("METADATA_HOST", "localhost")
+	metadataPort := help.GetEnv("METADATA_PORT", "8080")
+	typesenseHost := help.GetEnv("TYPESENSE_HOST", "localhost")
+	typesensePort := help.GetEnv("TYPESENSE_PORT", "8108")
 	typesenseEndpoint := fmt.Sprintf("http://%s:%s", typesenseHost, typesensePort)
-	typesenseApiKey := os.Getenv("TYPESENSE_APIKEY")
+	typesenseApiKey := help.GetEnv("TYPESENSE_APIKEY", "xyz")
 	logger.Infof("Connecting to typesense at: %s\n", typesenseEndpoint)
 	typesenseClient = typesense.NewClient(
 		typesense.WithServer(typesenseEndpoint),
@@ -977,7 +977,7 @@ func main() {
 	if err != nil {
 		logger.Panicw("Failed to create server", "error", err)
 	}
-	metadataHTTPPort := os.Getenv("METADATA_HTTP_PORT")
+	metadataHTTPPort := help.GetEnv("METADATA_HTTP_PORT", "3001")
 	metadataServingPort := fmt.Sprintf(":%s", metadataHTTPPort)
 	logger.Infof("Serving HTTP Metadata on port: %s\n", metadataServingPort)
 	metadata_server.Start(metadataServingPort)
diff --git a/metadata/etcd.go b/metadata/etcd.go
index 02b7ec47ae..46751b7aa4 100644
--- a/metadata/etcd.go
+++ b/metadata/etcd.go
@@ -10,6 +10,7 @@ import (
 	"fmt"
 	help "github.com/featureform/helpers"
 	pb "github.com/featureform/metadata/proto"
+	"github.com/pkg/errors"
 	clientv3 "go.etcd.io/etcd/client/v3"
 	"google.golang.org/protobuf/proto"
 	"time"
@@ -309,7 +310,7 @@ func (lookup etcdResourceLookup) serializeResource(res Resource) ([]byte, error)
 func (lookup etcdResourceLookup) deserialize(value []byte) (EtcdRow, error) {
 	var tmp EtcdRowTemp
 	if err := json.Unmarshal(value, &tmp); err != nil {
-		return EtcdRow{}, fmt.Errorf("failed To Parse Resource: %w: %s", err, value)
+		return EtcdRow{}, errors.Wrap(err, fmt.Sprintf("failed To Parse Resource: %s", value))
 	}
 	msg := EtcdRow{
 		ResourceType: ResourceType(tmp.ResourceType),
@@ -328,15 +329,15 @@ func (lookup etcdResourceLookup) Lookup(id ResourceID) (Resource, error) {
 	}
 	msg, err := lookup.deserialize(resp)
 	if err != nil {
-		return nil, fmt.Errorf("lookup deserialize err: %w id: %s", err, id)
+		return nil, errors.Wrap(err, fmt.Sprintf("lookup deserialize: %s", id))
 	}
 	resType, err := lookup.createEmptyResource(msg.ResourceType)
 	if err != nil {
-		return nil, fmt.Errorf("lookup create err: %w id: %s", err, id)
+		return nil, errors.Wrap(err, fmt.Sprintf("lookup create: %s", id))
 	}
 	resource, err := lookup.connection.ParseResource(msg, resType)
 	if err != nil {
-		return nil, fmt.Errorf("lookup parse: %w %s", err, id)
+		return nil, errors.Wrap(err, fmt.Sprintf("lookup parse: %s", id))
 	}
 	return resource, nil
 }
@@ -435,17 +436,17 @@ func (lookup etcdResourceLookup) Submap(ids []ResourceID) (ResourceLookup, error
 		}
 		etcdStore, err := lookup.deserialize(value)
 		if err != nil {
-			return nil, fmt.Errorf("submap %v: %v", id, err)
+			return nil, errors.Wrap(err, fmt.Sprintf("submap deserialize: %s", id))
 		}
 
 		resource, err := lookup.createEmptyResource(etcdStore.ResourceType)
 		if err != nil {
-			return nil, err
+			return nil, errors.Wrap(err, fmt.Sprintf("submap create empty resource: %s", id))
 		}
 
 		res, err := lookup.connection.ParseResource(etcdStore, resource)
 		if err != nil {
-			return nil, err
+			return nil, errors.Wrap(err, fmt.Sprintf("submap parse resource: %s", id))
 		}
 		resources[id] = res
 	}
@@ -456,16 +457,16 @@ func (lookup etcdResourceLookup) ListForType(t ResourceType) ([]Resource, error)
 	resources := make([]Resource, 0)
 	resp, err := lookup.connection.GetWithPrefix(t.String())
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, fmt.Sprintf("could not get prefix: %s", t))
 	}
 	for _, res := range resp {
 		etcdStore, err := lookup.deserialize(res)
 		if err != nil {
-			return nil, fmt.Errorf("listfortype %v: %v", resp, err)
+			return nil, errors.Wrap(err, fmt.Sprintf("could not deserialize: %s", res))
 		}
 		resource, err := lookup.createEmptyResource(etcdStore.ResourceType)
 		if err != nil {
-			return nil, err
+			return nil, errors.Wrap(err, fmt.Sprintf("could not create empty resource: %s", res))
 		}
 		resource, err = lookup.connection.ParseResource(etcdStore, resource)
 		if resource.ID().Type == t {
@@ -479,16 +480,16 @@ func (lookup etcdResourceLookup) List() ([]Resource, error) {
 	resources := make([]Resource, 0)
 	resp, err := lookup.connection.GetWithPrefix("")
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, fmt.Sprintf("could not get prefix: %v", resources))
 	}
 	for _, res := range resp {
 		etcdStore, err := lookup.deserialize(res)
 		if err != nil {
-			return nil, fmt.Errorf("list %v: %v", res, err)
+			return nil, errors.Wrap(err, fmt.Sprintf("list deserialize: %s", res))
 		}
 		resource, err := lookup.createEmptyResource(etcdStore.ResourceType)
 		if err != nil {
-			return nil, err
+			return nil, errors.Wrap(err, fmt.Sprintf("list create empty resource: %s", res))
 		}
 		resource, err = lookup.connection.ParseResource(etcdStore, resource)
 		resources = append(resources, resource)
@@ -499,13 +500,13 @@ func (lookup etcdResourceLookup) List() ([]Resource, error) {
 func (lookup etcdResourceLookup) SetStatus(id ResourceID, status pb.ResourceStatus) error {
 	res, err := lookup.Lookup(id)
 	if err != nil {
-		return fmt.Errorf("etcd: could not lookup: %w", err)
+		return errors.Wrap(err, fmt.Sprintf("could not lookup ID: %v", id))
 	}
 	if err := res.UpdateStatus(status); err != nil {
-		return fmt.Errorf("etcd: could not update: %w", err)
+		return errors.Wrap(err, fmt.Sprintf("could not update ID: %v", id))
 	}
 	if err := lookup.Set(id, res); err != nil {
-		return fmt.Errorf("etcd: could not set: %w", err)
+		return errors.Wrap(err, fmt.Sprintf("could not set ID: %v", id))
 	}
 	return nil
 }
diff --git a/metadata/metadata.go b/metadata/metadata.go
index 4b7982ba37..b356d2e091 100644
--- a/metadata/metadata.go
+++ b/metadata/metadata.go
@@ -7,6 +7,7 @@ package metadata
 import (
 	"context"
 	"fmt"
+	"github.com/pkg/errors"
 	"io"
 	"net"
 	"strings"
@@ -390,7 +391,7 @@ func (resource *sourceVariantResource) Dependencies(lookup ResourceLookup) (Reso
 	}
 	deps, err := lookup.Submap(depIds)
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, fmt.Sprintf("could not create submap for IDs: %v", depIds))
 	}
 	return deps, nil
 }
@@ -511,7 +512,7 @@ func (resource *featureVariantResource) Dependencies(lookup ResourceLookup) (Res
 	}
 	deps, err := lookup.Submap(depIds)
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, fmt.Sprintf("could not create submap for IDs: %v", depIds))
 	}
 	return deps, nil
 }
@@ -627,7 +628,7 @@ func (resource *labelVariantResource) Dependencies(lookup ResourceLookup) (Resou
 	}
 	deps, err := lookup.Submap(depIds)
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, fmt.Sprintf("could not create submap for IDs: %v", depIds))
 	}
 	return deps, nil
 }
@@ -744,7 +745,7 @@ func (resource *trainingSetVariantResource) Dependencies(lookup ResourceLookup)
 	}
 	deps, err := lookup.Submap(depIds)
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, fmt.Sprintf("could not create submap for IDs: %v", depIds))
 	}
 	return deps, nil
 }
@@ -809,7 +810,7 @@ func (resource *modelResource) Dependencies(lookup ResourceLookup) (ResourceLook
 	}
 	deps, err := lookup.Submap(depIds)
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, fmt.Sprintf("could not create submap for IDs: %v", depIds))
 	}
 	return deps, nil
 }
@@ -1003,7 +1004,7 @@ func NewMetadataServer(config *Config) (*MetadataServer, error) {
 	lookup, err := config.StorageProvider.GetResourceLookup()
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("could not configure storage provider: %v", err)
 	}
 	if config.TypeSenseParams != nil {
 		searcher, errInitializeSearch := search.NewTypesenseSearch(config.TypeSenseParams)
@@ -1081,7 +1082,7 @@ type EtcdStorageProvider struct {
 func (sp EtcdStorageProvider) GetResourceLookup() (ResourceLookup, error) {
 	client, err := sp.Config.initClient()
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("could not init etcd client: %v", err)
 	}
 	lookup := etcdResourceLookup{
 		connection: EtcdStorage{
@@ -1354,6 +1355,8 @@ func (serv *MetadataServer) genericCreate(ctx context.Context, res Resource, ini
 		}
 	}
 	if err := serv.propogateChange(res); err != nil {
+		err := errors.Wrap(err, fmt.Sprintf("could not propogate: %s", res))
+		serv.Logger.Error(errors.WithStack(err))
 		return nil, err
 	}
 	return &pb.Empty{}, nil
@@ -1366,11 +1369,11 @@ func (serv *MetadataServer) propogateChange(newRes Resource) error {
 	propogateChange = func(parent Resource) error {
 		deps, err := parent.Dependencies(serv.lookup)
 		if err != nil {
-			return err
+			return errors.Wrap(err, fmt.Sprintf("could not get dependencies for parent: %s", parent))
 		}
 		depList, err := deps.List()
 		if err != nil {
-			return err
+			return errors.Wrap(err, fmt.Sprintf("could not get dependencies list for parent: %s", parent))
 		}
 		for _, res := range depList {
 			id := res.ID()
diff --git a/metadata/server/server.go b/metadata/server/server.go
index 1769cc037d..0a2ff3fef3 100644
--- a/metadata/server/server.go
+++ b/metadata/server/server.go
@@ -6,20 +6,21 @@ package main
 
 import (
 	"fmt"
+	"github.com/featureform/metadata/search"
 	"os"
 
 	help "github.com/featureform/helpers"
 	"github.com/featureform/metadata"
-	"github.com/featureform/metadata/search"
 	"go.uber.org/zap"
 )
 
 func main() {
 
 	etcdHost := help.GetEnv("ETCD_HOST", "localhost")
-	etcdPort := help.GetEnv("ETCD_PORT", "6379")
+	etcdPort := help.GetEnv("ETCD_PORT", "2379")
 	logger := zap.NewExample().Sugar()
 	addr := help.GetEnv("METADATA_PORT", "8080")
+	enableTypesense := help.GetEnv("ENABLE_TYPESENSE", "true")
 	storageProvider := metadata.EtcdStorageProvider{
 		metadata.EtcdConfig{
 			Nodes: []metadata.EtcdNode{
@@ -27,17 +28,20 @@ func main() {
 			},
 		},
 	}
-	fmt.Println("TS Port", os.Getenv("TYPESENSE_PORT"), "TS HOST", os.Getenv("TYPESENSE_HOST"), "TS KEY", os.Getenv("TYPESENSE_APIKEY"))
 	config := &metadata.Config{
-		Logger:  logger,
-		Address: fmt.Sprintf(":%s", addr),
-		TypeSenseParams: &search.TypeSenseParams{
+		Logger:          logger,
+		Address:         fmt.Sprintf(":%s", addr),
+		StorageProvider: storageProvider,
+	}
+	if enableTypesense == "true" {
+		fmt.Println("TS Port", os.Getenv("TYPESENSE_PORT"), "TS HOST", os.Getenv("TYPESENSE_HOST"), "TS KEY", os.Getenv("TYPESENSE_APIKEY"))
+		config.TypeSenseParams = &search.TypeSenseParams{
 			Port:   help.GetEnv("TYPESENSE_PORT", "8108"),
 			Host:   help.GetEnv("TYPESENSE_HOST", "localhost"),
 			ApiKey: help.GetEnv("TYPESENSE_APIKEY", "xyz"),
-		},
-		StorageProvider: storageProvider,
+		}
 	}
+
 	server, err := metadata.NewMetadataServer(config)
 	if err != nil {
 		logger.Panicw("Failed to create metadata server", "Err", err)
diff --git a/newserving/Dockerfile b/newserving/Dockerfile
index 059b210222..f5db3ee17e 100644
--- a/newserving/Dockerfile
+++ b/newserving/Dockerfile
@@ -29,5 +29,6 @@ FROM alpine
 COPY --from=0 ./app/main ./main
 COPY --from=0 ./app/provider/scripts/offline_store_spark_runner.py /app/provider/scripts/offline_store_spark_runner.py
 
+ENV SERVING_PORT "8080"
 EXPOSE 8080
 ENTRYPOINT ["./main"]
\ No newline at end of file
diff --git a/newserving/main/main.go b/newserving/main/main.go
index ec1486691a..a4dd1fa01d 100644
--- a/newserving/main/main.go
+++ b/newserving/main/main.go
@@ -20,14 +20,16 @@ import (
 func main() {
 	logger := zap.NewExample().Sugar()
 
+	host := help.GetEnv("SERVING_HOST", "0.0.0.0")
 	port := help.GetEnv("SERVING_PORT", "8080")
-	lis, err := net.Listen("tcp", port)
+	address := fmt.Sprintf("%s:%s", host, port)
+	lis, err := net.Listen("tcp", address)
 	if err != nil {
 		logger.Panicw("Failed to listen on port", "Err", err)
 	}
 
 	promMetrics := metrics.NewMetrics("test")
-	metricsPort := help.GetEnv("METRICS_PORT", "")
+	metricsPort := help.GetEnv("METRICS_PORT", ":9090")
 
 	metadataHost := help.GetEnv("METADATA_HOST", "localhost")
 	metadataPort := help.GetEnv("METADATA_PORT", "8080")
@@ -39,15 +41,15 @@ func main() {
 	}
 
 	serv, err := newserving.NewFeatureServer(meta, promMetrics, logger)
-
-	grpcServer := grpc.NewServer()
 	if err != nil {
 		logger.Panicw("Failed to create training server", "Err", err)
 	}
+	grpcServer := grpc.NewServer()
+
 	pb.RegisterFeatureServer(grpcServer, serv)
 	logger.Infow("Serving metrics", "Port", metricsPort)
 	go promMetrics.ExposePort(metricsPort)
-	logger.Infow("Server starting", "Port", port)
+	logger.Infow("Server starting", "Port", address)
 	serveErr := grpcServer.Serve(lis)
 	if serveErr != nil {
 		logger.Errorw("Serve failed with error", "Err", serveErr)
diff --git a/newserving/serving.go b/newserving/serving.go
index 84d2029262..54b1d23128 100644
--- a/newserving/serving.go
+++ b/newserving/serving.go
@@ -7,6 +7,7 @@ package newserving
 import (
 	"context"
 	"fmt"
+	"github.com/pkg/errors"
 
 	"github.com/featureform/metadata"
 	"github.com/featureform/metrics"
@@ -70,22 +71,22 @@ func (serv *FeatureServer) getTrainingSetIterator(name, variant string) (provide
 	serv.Logger.Infow("Getting Training Set Iterator", "name", name, "variant", variant)
 	ts, err := serv.Metadata.GetTrainingSetVariant(ctx, metadata.NameVariant{name, variant})
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, "could not get training set variant")
 	}
 	serv.Logger.Debugw("Fetching Training Set Provider", "name", name, "variant", variant)
 	providerEntry, err := ts.FetchProvider(serv.Metadata, ctx)
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, "could not get fetch provider")
 	}
 	p, err := provider.Get(provider.Type(providerEntry.Type()), providerEntry.SerializedConfig())
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, "could not get provider")
 	}
 	store, err := p.AsOfflineStore()
 	if err != nil {
 		// This means that the provider of the training set isn't an offline store.
 		// That shouldn't be possible.
-		return nil, err
+		return nil, errors.Wrap(err, "could not open as offline store")
 	}
 	serv.Logger.Debugw("Get Training Set From Store", "name", name, "variant", variant)
 	return store.GetTrainingSet(provider.ResourceID{Name: name, Variant: variant})
@@ -104,7 +105,7 @@ func (serv *FeatureServer) FeatureServe(ctx context.Context, req *pb.FeatureServ
 		serv.Logger.Infow("Serving feature", "Name", name, "Variant", variant)
 		val, err := serv.getFeatureValue(ctx, name, variant, entityMap)
 		if err != nil {
-			return nil, err
+			return nil, errors.Wrap(err, "could not get feature value")
 		}
 		vals[i] = val
 	}
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000000..8986f6f743
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,20 @@
+events {
+  worker_connections  1024;  ## Default: 1024
+}
+http{
+    server {
+        listen 80;
+        listen [::]:80;
+
+        root /app/dashboard/out;
+        index index.html index.htm;
+
+        server_name _;
+        location / {
+            try_files $uri $uri/ /index.html;
+        }
+        location /data {
+            proxy_pass  http://127.0.0.1:3001/data;
+        }
+    }
+}
\ No newline at end of file
diff --git a/serving.py b/serving.py
new file mode 100644
index 0000000000..e36a809051
--- /dev/null
+++ b/serving.py
@@ -0,0 +1,7 @@
+from featureform import ServingClient
+
+serving = ServingClient(insecure=True)
+
+user_feat = serving.features(["avg_transactions"], {"user": "C1214240"})
+print("User Result: ")
+print(user_feat)
diff --git a/supervisord.conf b/supervisord.conf
new file mode 100644
index 0000000000..a2fdfed5e7
--- /dev/null
+++ b/supervisord.conf
@@ -0,0 +1,55 @@
+[supervisord]
+nodaemon=true
+
+[program:api]
+command=/bin/bash -c "./execs/api"
+stdout_events_enabled=true
+stderr_events_enabled=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+
+[program:metadata]
+command=/bin/bash -c "./execs/metadata"
+stdout_events_enabled=true
+stderr_events_enabled=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+
+[program:etcd]
+command=/bin/bash -c "%(ENV_ETCD_ARCH)s ./etcd/bin/etcd"
+stdout_events_enabled=true
+stderr_events_enabled=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+
+[program:coordinator]
+command=/bin/bash -c "./execs/coordinator"
+stdout_events_enabled=true
+stderr_events_enabled=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+
+[program:dashboard]
+command=/usr/sbin/nginx -g "daemon off;"
+autostart=true
+autorestart=true
+startretries=5
+numprocs=1
+stdout_events_enabled=true
+stderr_events_enabled=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+
+[program:dashboard_backend]
+command=/bin/bash -c "./execs/dashboard_metadata"
+stdout_events_enabled=true
+stderr_events_enabled=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+
+[program:serving]
+command=/bin/bash -c "./execs/serving"
+stdout_events_enabled=true
+stderr_events_enabled=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
diff --git a/training.py b/training.py
new file mode 100644
index 0000000000..14c3a45cdd
--- /dev/null
+++ b/training.py
@@ -0,0 +1,9 @@
+from featureform import ServingClient
+
+client = ServingClient(insecure=True)
+dataset = client.training_set("fraud_training")
+
+for i, batch in enumerate(dataset):
+    print(batch)
+    if i > 25:
+        break
\ No newline at end of file


Test patch:
diff --git a/client/tests/test_cli.py b/client/tests/test_cli.py
new file mode 100644
index 0000000000..b99735aee5
--- /dev/null
+++ b/client/tests/test_cli.py
@@ -0,0 +1,37 @@
+import pytest
+import sys
+from click.testing import CliRunner
+
+sys.path.insert(0, 'client/src/')
+from featureform.cli import apply
+
+class TestApply:
+    def test_invalid_empty(self):
+        runner = CliRunner()
+        result = runner.invoke(apply)
+        assert result.exit_code == 2
+
+    def test_invalid_path(self):
+        runner = CliRunner()
+        with pytest.raises(ValueError, match="Argument must be a path to a file or URL with a valid schema"):
+            runner.invoke(apply, ". --dry-run".split(), catch_exceptions=False)
+
+    def test_invalid_url(self):
+        runner = CliRunner()
+        with pytest.raises(ValueError, match="Argument must be a path to a file or URL with a valid schema"):
+            runner.invoke(apply, "www.something.com --dry-run".split(), catch_exceptions=False)
+
+    def test_valid_url(self):
+        runner = CliRunner()
+        result = runner.invoke(apply, "https://featureform-demo-files.s3.amazonaws.com/quickstart.py --dry-run".split(), catch_exceptions=False)
+        assert result.exit_code == 0
+
+    def test_valid_file(self):
+        runner = CliRunner()
+        result = runner.invoke(apply, "client/examples/quickstart.py --dry-run".split(), catch_exceptions=False)
+        assert result.exit_code == 0
+
+    def test_multiple_values(self):
+        runner = CliRunner()
+        result = runner.invoke(apply, "client/examples/quickstart.py https://featureform-demo-files.s3.amazonaws.com/quickstart.py --dry-run".split(), catch_exceptions=False)
+        assert result.exit_code == 0
\ No newline at end of file