Anyway around getting aws_instance.*.public_dns in a template file?

I am trying to get the public_dns from an aws_resource in a template file. I am getting the following error:

Error: Cycle: data.template_file.jupyter, aws_instance.jupyter

I am guessing that this is because the template file is created before the public_dns is made after a template file is rendered?

Can anyone think of a way to get the public_dns from the aws_resource into my user_data/template script?

Here’s main.tf

provider "aws" {
  region  = "us-west-2"
}

data "aws_ami" "al2" {
  most_recent = true

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-ebs"]
  }

  owners = ["amazon"]
}

resource "tls_private_key" "key" {
  algorithm = "RSA"
  rsa_bits  = 4096
}

resource "aws_key_pair" "generated_key" {
  key_name   = "key-${uuid()}"
  public_key = "${tls_private_key.key.public_key_openssh}"
}

resource "local_file" "pem" {
  filename        = "${aws_key_pair.generated_key.key_name}.pem"
  content         = "${tls_private_key.key.private_key_pem}"
  file_permission = "400"
}

resource "aws_security_group" "jupyter" {
  name        = "${var.service}-${uuid()}"
  description = "Security group for ${title(var.service)}"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "SSH"
  }

  ingress {
    from_port   = 8888
    to_port     = 8898
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Jupyter Notebook"
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Contact     = "${var.contact}"
    Environment = "${title(var.environment)}"
    Name        = "${var.service}-${uuid()}"
    Service     = "${title(var.service)}"
    Terraform   = "true"
  }
}

resource "aws_instance" "jupyter" {
  ami                     = "${data.aws_ami.al2.id}"
  instance_type           = "${var.instance_type}"
  key_name                = "${aws_key_pair.generated_key.key_name}"
  vpc_security_group_ids  = ["${aws_security_group.jupyter.id}"]
  user_data               = "${data.template_file.jupyter.rendered}"
  disable_api_termination = false

  tags = {
    Name        = "${title(var.service)}-${timestamp()}"
    Service     = "${title(var.service)}"
    Contact     = "${var.contact}"
    Environment = "${title(lower(var.environment))}"
    Terraform   = "true"
  }

  volume_tags = {
    Name        = "${title(var.service)}-${timestamp()}_ROOT"
    Service     = "${title(var.service)}"
    Contact     = "${var.contact}"
    Environment = "${title(lower(var.environment))}"
    Terraform   = "true"
  }
}

resource "aws_ebs_volume" "jupyter" {
  availability_zone = "${var.availability_zone}"
  size              = 20
  type              = "gp2"

  tags = {
    Name        = "${title(var.service)}-${timestamp()}_Anaconda3"
    Service     = "${var.service}"
    Contact     = "${var.contact}"
    Environment = "${title(lower(var.environment))}"
    Terraform   = "true"
  }
}

resource "aws_volume_attachment" "jupyter" {
  device_name  = "/dev/sdb"
  instance_id  = "${aws_instance.jupyter.id}"
  volume_id    = "${aws_ebs_volume.jupyter.id}"
  force_detach = true
}

data "template_file" "jupyter" {
  template = "${file("script.tpl")}"
  vars = {
    publicdns = "${aws_instance.jupyter.public_dns}"
  }
}

terraform {
  backend "local" {
  }
}

Here’s my script/template/user_data:

#!/bin/bash
sleep 2m
# Log stdout to file
exec 3>&1 4>&2
trap 'exec 2>&4 1>&3' 0 1 2 3
exec 1>/home/ec2-user/terraform-log.out 2>&1
# Update AL2
sudo yum update -y
# Mount /anaconda
sudo mkfs.xfs /dev/sdb -f
sudo mkdir /anaconda3
sudo mount /dev/sdb /anaconda3
sudo chown -R ec2-user:ec2-user /anaconda3
sudo echo "UUID=$(lsblk -nr -o UUID,MOUNTPOINT | grep "/anaconda3" | cut -d ' ' -f 1) /anaconda3 xfs defaults,nofail 1 2" >> /etc/fstab
# Install Anaconda
wget https://repo.anaconda.com/archive/Anaconda3-2018.12-Linux-x86_64.sh -O /home/ec2-user/anaconda.sh &&
    bash /home/ec2-user/anaconda.sh -u -b -p /anaconda3 &&
    echo 'export PATH="/anaconda3/bin:$PATH"' >> /home/ec2-user/.bashrc &&
    source /home/ec2-user/.bashrc &&
    rm -rf /home/ec2-user/anaconda.sh &&
# Configure Jupyter for AWS HTTP
sudo su ec2-user &&
jupyter notebook --generate-config &&
    sed -i -e "s/#c.NotebookApp.ip = 'localhost'/c.NotebookApp.ip = '${publicdns}'/g" /home/ec2-user/.jupyter/jupyter_notebook_config.py &&
    sed -i -e "s/#c.NotebookApp.allow_origin = ''/c.NotebookApp.allow_origin = '*'/g" /home/ec2-user/.jupyter/jupyter_notebook_config.py &&
    sed -i -e "s/#c.NotebookApp.open_browser = True/c.NotebookApp.open_browser = False/g" /home/ec2-user/.jupyter/jupyter_notebook_config.py

Hi @wblakecannon,

Unfortunately, as you feared this sort of thing isn’t possible because the public IP address and therefore the public hostname of an EC2 instance are assigned only once it’s already been created.

There are two ways you could accomplish a similar result, which each have some different tradeoffs:

  • You could use a separate aws_network_interface resource to reserve the IP address first, and then connect it to your EC2 instance using a network_interface block inside aws_instance. Then you can render your template using aws_network_interface.example.public_dns instead of getting the hostname from the EC2 instance, but it does mean managing your network interface separately from your instance which will affect some behaviors. For example, if the aws_instance is planned for replacement due to a configuration change, the network interface won’t be replaced along with it. That means you won’t be able to use create_before_destroy on the instance because otherwise Terraform would attempt to briefly connect the same network interface to both the old and new instances, which is impossible.

  • Alternatively, you could avoid passing the DNS name into the user_data at all and instead have your initialization script discover the network configuration for itself via the instance metadata API. As you’ll see on that page, you can access the public hostname of an EC2 instance from that instance by retrieving http://169.254.169.254/latest/meta-data/public-hostname. This avoids an unusual Terraform configuration, but requires a little more complexity in your initialization script.

Given that the EC2 instance metadata API is quite easy to access from a shell script as long as you have curl available, I’d personally prefer the second option if I were in your situation, because it seems simpler overall. But I believe both options should work, if this second option is inappropriate for some reason.


Note also that since you are using Terraform 0.12 you don’t need to use the template_file data source anymore. Its functionality has been replaced with the built-in templatefile function, which you can use directly in the user_data argument without needing a separate data block at all.

  user_data = templatefile("${path.module}/script.tpl", {
    public_dns = aws_network_interface.example.public_dns
  })

If you take option 2 above, though, you likely won’t need templating at all because you script will just be a static file which you can access using file("${path.module}/script.sh").

Very cool Martin.

  1. Didn’t know about the templatefile() function. Will start using that instead of the data source. Thanks
  2. I was able to just update one of the lines of my user_data script to:

sed -i -e "s/#c.NotebookApp.ip = 'localhost'/c.NotebookApp.ip = '"$(curl http://169.254.169.254/latest/meta-data/public-hostname)"'/g" /home/ec2-user/.jupyter/jupyter_notebook_config.py

That command works. However now I’m getting an issue where it doesn’t seem to generate the config for the ec2-user. Do start up scripts run as root? I tried witching to ec2-user then running 'Jupyter notebook --generate-config` but it still creates the config under root.

Nevermind I figured it out by using:
runuser -l ec2-user -c 'jupyter notebook --generate-config'