When deploying Docker containers to AWS ECS, you can encounter a situation where you want to run an image that requires some configuration. For example, let's say you wanted to run Vector1 as a sidecar to your main application so you can ship your application's metrics to a service like Honeybadger Insights. To run Vector, you only need to provide one configuration file (/etc/vector/vector.yaml) to the image available on Docker Hub. However, creating your own image that just adds one file would be a hassle. It would be easier if you could pull the public image, add your config, and deploy that. But ECS doesn't allow you to mount a file when running the container like you can when running Docker on your laptop or a VM. There is a way to do it on ECS, though — let's check it out.

Services and Tasks

But first, a little terminology. Running a Docker container on ECS requires you to create a task definition that specifies what image(s) you want to run, what the command should be, what the environment variables are, etc. Continuing our example, a task definition that runs Vector looks like this:

{
  "containerDefinitions":[
    {
      "name": "vector",
      "image": "timberio/vector:0.38.0-alpine",
      "essential": true,
      "environment": []
    }
  ]
}

Of course, this configuration won't do us much good as-is — it will run Vector, but there won't be any Vector configuration, so Vector won't be doing anything at all. We'll fix that in a bit. :)

An ECS service runs your tasks (made up of one or more images) on your own EC2 instances or instances managed by AWS (known as Fargate). We'll assume you're using Fargate for this tutorial. Each service definition specifies how many copies of the task definition you want to run (e.g., two or more for redundancy), what security group to use, the ports to forward to the containers, and so on. In other words, your task definition specifies the Docker-specific stuff like the image to use, and the service specifies how to run it in the AWS environment.

With that out of the way, we can return to the task at hand (pun intended).

Configuring a container

You might have a container that's configured entirely by environment variables. If that's the case, then you can use the environment section of the task definition to handle that:

  "environment": [
    {
      "name": "ENVIRONMENT",
      "value": "production"
    },
    {
      "name": "LOG_LEVEL",
      "value": "info"
    }
  ]

But you have to do a bit more work to get a configuration file to show up. I'll drop a task definition on you, then walk through the key points.

{
  "containerDefinitions":[
    {
      "name": "vector",
      "image": "timberio/vector:0.38.0-alpine",
      "mountPoints": [
        {
          "sourceVolume": "vector-config",
          "containerPath": "/etc/vector"
        }
      ],
      "dependsOn": [
        {
          "containerName": "vector-config",
          "condition": "COMPLETE"
        }
      ],
    },
    {
      "name": "vector-config",
      "image": "bash",
      "essential": false,
      "command": [
        "sh",
        "-c",
        "echo $VECTOR_CONFIG | base64 -d - | tee /etc/vector/vector.yaml"
      ],
      "environment": [
        {
          "name": "VECTOR_CONFIG",
          "value": "Contents of a config file go here"
        }
      ],
      "mountPoints": [
        {
          "sourceVolume": "vector-config",
          "containerPath": "/etc/vector"
        }
      ]
    }
  ]
}

There are a few things to notice here:

  • There are two containers instead of just one. This is how you run a sidecar (running an app container and a logging container side by side) or, in this case, bootstrapping one container with another one.
  • Both containers share a mountpoint (vector-config) at the same location (/etc/vector). The containerPath doesn't have to be the same, but the sourceVolume does. This allows one container to write to a file and the other container to be able to read that same file.
  • The vector container depends on the vector-config container and waits to boot until the vector-config container has run its command.
  • The command for the vector-config container populates a configuration file with the contents of an environment variable called VECTOR_CONFIG.

That's the bones of getting a file mounted for the Docker container. An initializer container creates the file on a shared volume; then, another container can read the file. But how do we get the contents of our config file into that environment variable, and what's with the base64 -d - thing?

Terraform it

Terraform is a handy tool for automating the deployment of cloud infrastructure. It works with all kinds of clouds and is great for documenting and tracking your infrastructure changes. For this tutorial, we'll focus on just one Terraform resource — the one that can create our task definition and populate the configuration:

resource "aws_ecs_task_definition" "vector" {
  family                   = "vector"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"

  volume {
    name = "vector-config"
  }

  container_definitions = jsonencode([
    {
      name      = "vector"
      image     = "timberio/vector:0.38.0-alpine"
      essential = true
      mountPoints = [
        {
          sourceVolume  = "vector-config"
          containerPath = "/etc/vector"
        }
      ],
      dependsOn = [
        {
          containerName = "vector-config"
          condition     = "COMPLETE"
        }
      ]
    },
    {
      name      = "vector-config"
      image     = "bash"
      essential = false
      command = [
        "sh",
        "-c",
        "echo $VECTOR_CONFIG | base64 -d - | tee /etc/vector/vector.yaml"
      ],
      environment = [
        {
          name  = "VECTOR_CONFIG"
          value = base64encode(file("vector.yaml"))
        }
      ],
      mountPoints = [
        {
          sourceVolume  = "vector-config"
          containerPath = "/etc/vector"
        }
      ],
    }
  ])
}

That looks pretty familiar, right? Terraform does a good job of sticking closely to the formats used by the various cloud providers. In this case, the aws_ecs_task_definition resource looks like the JSON used in task definitions. Note how the VECTOR_CONFIG environment variable is populated. Terraform provides file and base64encode helpers to read a file's contents and encode it, respectively2.

Our actual Vector config (that ends up at /etc/vector/vector.yaml) is stored in a file next to our Terraform config. It could look something like this:

sources:
  app_metrics:
    type: prometheus_scrape
    endpoints:
      - http://localhost:9090/metrics

sinks:
  honeybadger_insights:
    type: "http"
    inputs: ["app_metrics"]
    uri: "https://api.honeybadger.io/v1/events"
    request:
      headers:
        X-API-Key: "hbp_123"
    encoding:
      codec: "json"
    framing:
      method: "newline_delimited"

Diving into how Vector works could be a whole 'nother blog post, but here's a quick run-down on what we're configuring our Vector sidecar to do. We first define a source, or in other words, something that emits some data for Vector to process. Vector supports many sources, like S3 buckets, Kafka topics, etc. We're telling Vector to scrape Prometheus metrics served by our application on port 90903. The sink configuration sends data from Vector to someplace else — in this case, to Honeybadger Insights.

That's a wrap

So, that's how you can deploy a Docker image to AWS ECS with a custom configuration without having to build and host a custom image. All it takes is a little bit of Terraform!


  1. Vector is an open-source, high-performance observability data platform for collecting, transforming, and shipping logs, metrics, and traces from various sources to a wide array of destinations. 

  2. Using Base64 encoding via the base64encode Terraform helper and decoding via the base64 -d - command allows us to avoid problems with quotes and other characters breaking the task definition's JSON configuration. 

  3. For example, you can use a Prometheus exporter in your Rails app to get metrics that look like this to be served on port 9090. 

Get the Honeybadger newsletter

Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo
    Benjamin Curtis

    Ben has been developing web apps and building startups since '99, and fell in love with Ruby and Rails in 2005. Before co-founding Honeybadger, he launched a couple of his own startups: Catch the Best, to help companies manage the hiring process, and RailsKits, to help Rails developers get a jump start on their projects. Ben's role at Honeybadger ranges from bare-metal to front-end... he keeps the server lights blinking happily, builds a lot of the back-end Rails code, and dips his toes into the front-end code from time to time. When he's not working, Ben likes to hang out with his wife and kids, ride his road bike, and of course hack on open source projects. :)

    More articles by Benjamin Curtis
    An advertisement for Honeybadger that reads 'Turn your logs into events.'

    "Splunk-like querying without having to sell my kidneys? nice"

    That’s a direct quote from someone who just saw Honeybadger Insights. It’s a bit like Papertrail or DataDog—but with just the good parts and a reasonable price tag.

    Best of all, Insights logging is available on our free tier as part of a comprehensive monitoring suite including error tracking, uptime monitoring, status pages, and more.

    Start logging for FREE
    Simple 5-minute setup — No credit card required