The Packer shell provisioner lets you execute arbitrary shell scripts on your machine image during the build process.

Let’s see it in action. Imagine you’re building a Debian 11 image and need to install nginx and configure it to serve a simple static page.

source "amazon-ebs" "example" {

  ami_name      = "example-nginx-{{timestamp}}"

  instance_type = "t2.micro"
  region        = "us-east-1"
  ssh_username  = "admin"
  ami_users     = ["admin"]
  tags = {
    Name = "example-nginx"
  }
}

build {
  sources = ["source.amazon-ebs.example"]

  provisioner "shell" {
    inline = [
      "sudo apt-get update -y",
      "sudo apt-get install -y nginx",
      "echo '<h1>Hello from Packer!</h1>' | sudo tee /var/www/html/index.nginx-debian.html",
      "sudo systemctl enable nginx",
      "sudo systemctl start nginx"
    ]
  }
}

When Packer runs this configuration, it will:

  1. Launch an EC2 instance of type t2.micro in us-east-1.
  2. Connect to it via SSH using the admin user.
  3. Execute the commands in the inline block sequentially.
  4. Update the package list.
  5. Install nginx.
  6. Create a simple index.html file.
  7. Ensure nginx starts on boot.
  8. Start the nginx service.
  9. Finally, shut down the instance and create an AMI from its root volume.

The shell provisioner is incredibly versatile. You can use inline for short, simple commands, or the script argument to point to a local shell script file. This is ideal for more complex provisioning tasks.

provisioner "shell" {
  script = "scripts/setup-webserver.sh"
  args   = ["--production", "us-west-2"] # Arguments passed to the script
}

In this example, Packer will upload scripts/setup-webserver.sh to the instance and execute it. Any arguments provided in the args field will be passed to the script, allowing for dynamic configuration.

The execute_command argument offers even finer control. By default, Packer uses ssh -o ... sudo {{.Path}}, but you can override this to use bash -c 'sudo {{.Path}}', or even a custom command that might involve docker exec if you’re provisioning inside a container.

Packer handles the transfer of files and execution context. When you use script, Packer uploads the script to a temporary location on the target machine and then executes it. If you need to transfer additional files, you can use the files argument.

provisioner "shell" {
  script = "scripts/deploy-app.sh"
  files  = ["config/app.conf", "assets/logo.png"]
}

This uploads config/app.conf and assets/logo.png to the temporary directory alongside the script before execution. The script can then reference these files using their relative paths.

The timeout setting is crucial for long-running scripts. By default, it’s 20 minutes. If your script might take longer, you’ll need to increase this value to prevent Packer from terminating it prematurely.

provisioner "shell" {
  script  = "scripts/long-process.sh"
  timeout = "1h" # Set timeout to 1 hour
}

You can also use expect_disconnect: true if your script is designed to intentionally disconnect the SSH session as part of its operation, such as rebooting the instance. Packer will wait for the specified connection_timeout before attempting to reconnect.

What often trips people up is understanding the execution environment. The shell provisioner runs on the instance being built, not on the machine where you’re running packer build. This means any commands must be valid for the target operating system and any tools must be pre-installed or installed by earlier provisioners. It’s a separate, ephemeral environment.

The shell provisioner is the workhorse for basic image customization, but for more complex application deployments or configuration management, you’ll likely move to provisioners like Ansible, Chef, or Puppet, which Packer can also orchestrate.

The next thing you’ll likely want to explore is how to manage secrets within your provisioner scripts without embedding them directly.

Want structured learning?

Take the full Packer course →