Skip to content

Containers

The Ansible container is published as four image variants:

Tag Cloud VS Code pre-installed Registry
aws Amazon Web Services No ghcr.io/sapphire-health/ansible-epic:aws
azure Microsoft Azure No ghcr.io/sapphire-health/ansible-epic:azure
aws-web Amazon Web Services Yes ghcr.io/sapphire-health/ansible-epic:aws-web
azure-web Microsoft Azure Yes ghcr.io/sapphire-health/ansible-epic:azure-web

The aws and azure variants share the same runtime behaviour. Substitute the appropriate tag in the examples below.

The aws-web and azure-web variants extend the base images with a pre-installed VS Code Server and the Ansible VS Code extension. Use these variants in environments with restricted outbound internet access where VS Code cannot be downloaded at runtime. See VS Code Web Server.

When running as a Coder workspace, the aws or azure base image is used. The Coder agent binary is downloaded at container startup directly from the Coder server, so the image never needs to be rebuilt when the Coder server is upgraded.

The container supports several runtime configurations. It can be accessed over SSH, via a VS Code tunnel, or as a Coder workspace, and optionally run a GitHub Actions self-hosted runner in the background concurrently with any access method.

Environment Variables

Variable Required Default Description
AUTHORIZED_KEYS No SSH public key(s). When set, starts sshd and accepts connections on port 22.
ANSIBLE_REPO_URL No HTTPS URL of the Ansible repository to clone on first start.
GITHUB_TOKEN No GitHub PAT used to clone a private repository. See PAT Requirements.
TUNNEL_NAME No ansible_container VS Code tunnel display name. Used when AUTHORIZED_KEYS is not set.
GITHUB_RUNNER_URL No Repository or organization URL to register the GitHub Actions runner against, e.g. https://github.com/org/repo.
GITHUB_RUNNER_PAT No Long-lived GitHub PAT. The container exchanges this for a fresh registration token at startup. Recommended for automated deployments.
GITHUB_RUNNER_TOKEN No Short-lived GitHub Actions runner registration token. Use for manual or one-off deployments.
GITHUB_RUNNER_NAME No ECS task ID or hostname Runner display name shown in GitHub. When running in ECS the task ID is used automatically so each container gets a unique name. On other platforms the hostname is used.
GITHUB_RUNNER_LABELS No self-hosted,linux Comma-separated labels assigned to the runner.
RUNNER_DEBUG No When set, runner output goes to stdout. By default runner output is written to ~/runner.log.
CODER_AGENT_TOKEN No Coder workspace agent token. When set, starts the Coder agent instead of SSH or VS Code tunnel. See Coder Workspace.
CODER_AGENT_URL No URL of the Coder server, e.g. https://coder.example.com. Required when CODER_AGENT_TOKEN is set.
VSCODE_WEB No When set to any non-empty value, starts the VS Code web server on startup. Intended for use with the aws-web / azure-web image variants. See VS Code Web Server.
VSCODE_WEB_PORT No 8080 Port the VS Code web server listens on. Only used when VSCODE_WEB is set.

Access Modes

SSH

When AUTHORIZED_KEYS is set the container generates SSH host keys, writes the provided key(s) to ~/.ssh/authorized_keys, and starts sshd on port 22.

docker run -d \
  -p 22:22 \
  -e AUTHORIZED_KEYS="ssh-ed25519 AAAA... user@host" \
  ghcr.io/sapphire-health/ansible-epic:aws  # or :azure
docker run -d `
  -p 22:22 `
  -e AUTHORIZED_KEYS="ssh-ed25519 AAAA... user@host" `
  ghcr.io/sapphire-health/ansible-epic:aws  # or :azure

VS Code Tunnel

When AUTHORIZED_KEYS is not set the container launches a VS Code tunnel. A device login code is printed to the container logs each time the container starts until Microsoft credentials are cached inside the container.

docker run -d \
  -e TUNNEL_NAME="my-ansible-tunnel" \
  ghcr.io/sapphire-health/ansible-epic:aws  # or :azure
docker run -d `
  -e TUNNEL_NAME="my-ansible-tunnel" `
  ghcr.io/sapphire-health/ansible-epic:aws  # or :azure

Note

Check the container logs for a device login URL and code. Navigate to https://login.microsoft.com/device and enter the code to authenticate with your Microsoft account. The code refreshes every 15 minutes until authentication completes.

VS Code Web Server

When VSCODE_WEB is set, the container starts code serve-web on startup without requiring a Microsoft login or outbound tunnel. This mode is intended for the aws-web and azure-web image variants, which pre-install the VS Code Server and the Ansible extension so no downloads are needed at runtime.

The server binds to 0.0.0.0 when running standalone, making it accessible on the container's published port. When CODER_AGENT_TOKEN is also set the server binds to 127.0.0.1 instead, and the Coder agent proxies access through the Coder UI.

docker run -d \
  -p 8080:8080 \
  -e VSCODE_WEB=1 \
  ghcr.io/sapphire-health/ansible-epic:aws-web  # or :azure-web
docker run -d `
  -p 8080:8080 `
  -e VSCODE_WEB=1 `
  ghcr.io/sapphire-health/ansible-epic:aws-web  # or :azure-web

To use a custom port:

docker run -d \
  -p 8080:8080 \
  -e VSCODE_WEB=1 \
  -e VSCODE_WEB_PORT=8080 \
  ghcr.io/sapphire-health/ansible-epic:aws-web
docker run -d `
  -p 8080:8080 `
  -e VSCODE_WEB=1 `
  -e VSCODE_WEB_PORT=8080 `
  ghcr.io/sapphire-health/ansible-epic:aws-web

The GitHub Actions runner works alongside the VS Code web server when GITHUB_RUNNER_URL is also set.

Note

The VS Code web server has no authentication when started with --without-connection-token. Ensure the container port is not exposed to untrusted networks, or place a reverse proxy with authentication in front of it.

Coder Workspace

When CODER_AGENT_TOKEN is set the container downloads the Coder agent binary from CODER_AGENT_URL at startup, then starts it. The agent connects back to the Coder server and makes the container available as a workspace. VS Code (via code serve-web) and a terminal are accessible through the Coder UI without any additional configuration. Use the standard aws or azure image variants — no separate Coder-specific image is needed.

docker run -d \
  -e CODER_AGENT_TOKEN="<token>" \
  -e CODER_AGENT_URL="https://coder.example.com" \
  ghcr.io/sapphire-health/ansible-epic:aws  # or :azure
docker run -d `
  -e CODER_AGENT_TOKEN="<token>" `
  -e CODER_AGENT_URL="https://coder.example.com" `
  ghcr.io/sapphire-health/ansible-epic:aws  # or :azure

The GitHub Actions runner works alongside the Coder agent when GITHUB_RUNNER_URL is also set:

docker run -d \
  -e CODER_AGENT_TOKEN="<token>" \
  -e CODER_AGENT_URL="https://coder.example.com" \
  -e GITHUB_RUNNER_URL="https://github.com/Sapphire-Health/ansible-epic" \
  -e GITHUB_RUNNER_PAT="<pat>" \
  ghcr.io/sapphire-health/ansible-epic:aws
docker run -d `
  -e CODER_AGENT_TOKEN="<token>" `
  -e CODER_AGENT_URL="https://coder.example.com" `
  -e GITHUB_RUNNER_URL="https://github.com/Sapphire-Health/ansible-epic" `
  -e GITHUB_RUNNER_PAT="<pat>" `
  ghcr.io/sapphire-health/ansible-epic:aws

Obtaining the Agent Token

The agent token is generated when a Coder workspace is created from a template. To retrieve it for manual use:

coder state pull <workspace-name> | python3 -c "
import sys, json
state = json.load(sys.stdin)
print(state['outputs']['agent_token']['value'])
"
coder state pull <workspace-name> | python3 -c "import sys, json; state = json.load(sys.stdin); print(state['outputs']['agent_token']['value'])"

Coder Template

The Coder workspace templates for this container are in the ansible-epic repository under coder/dev-container/dev-container.tf (general-purpose development container) and coder/gh-runner/gh-runner.tf (development container with a GitHub Actions self-hosted runner). Both provision an AWS ECS Fargate task running the aws-coder image, backed by EFS for persistent storage, and wire up the Coder agent, VS Code web access, and optional tooling automatically.

This template is deployed to the Sapphire Health Coder instance at https://coder.sapphirehealth.org. Access requires a Sapphire Health account — sign in using Microsoft Entra ID (single sign-on) from the Coder login page.

GitHub Actions Runner

Setting GITHUB_RUNNER_URL and either GITHUB_RUNNER_PAT or GITHUB_RUNNER_TOKEN causes the container to configure and start a GitHub Actions self-hosted runner in the background before starting SSH or the VS Code tunnel. The runner and the access method run concurrently within the same container.

Automated Deployment (Terraform / ECS)

For automated deployments, supply a long-lived GitHub PAT via GITHUB_RUNNER_PAT. The container calls the GitHub API at startup to generate a fresh registration token each time the container starts, so no manual token rotation is required.

Store the PAT as an AWS Secrets Manager secret and inject it into the ECS task definition using the secrets field. ECS fetches the secret value and injects it as an environment variable before the container starts.

resource "aws_secretsmanager_secret" "github_runner_pat" {
  name = "ansible/github-runner-pat"
}

resource "aws_secretsmanager_secret_version" "github_runner_pat" {
  secret_id     = aws_secretsmanager_secret.github_runner_pat.id
  secret_string = var.github_runner_pat
}

Note

Store the secret as a plain string (the raw token value), not as a JSON object. The valueFrom field in the task definition references the secret ARN directly, which instructs ECS to inject the entire secret value as the environment variable. If the secret is stored as JSON (e.g. {"GITHUB_RUNNER_PAT":"github_pat_..."}), ECS will inject the JSON string rather than the token.

Reference the secret in the ECS task definition alongside the other runner environment variables:

resource "aws_ecs_task_definition" "ansible" {
  ...
  container_definitions = jsonencode([{
    ...
    secrets = [
      {
        name      = "GITHUB_RUNNER_PAT"
        valueFrom = aws_secretsmanager_secret.github_runner_pat.arn
      }
    ]
    environment = [
      { name = "GITHUB_RUNNER_URL",    value = "https://github.com/Sapphire-Health/ansible-epic" },
      { name = "GITHUB_RUNNER_NAME",   value = "ansible-container" },
      { name = "GITHUB_RUNNER_LABELS", value = "self-hosted,linux,aws" }
    ]
  }])
}

Note

The ECS task execution role must have secretsmanager:GetSecretValue on the secret ARN. See AWS Container Requirements.

Manual Token

For testing or one-off deployments, supply a short-lived registration token directly via GITHUB_RUNNER_TOKEN:

docker run -d \
  -p 22:22 \
  -e AUTHORIZED_KEYS="ssh-ed25519 AAAA... user@host" \
  -e GITHUB_RUNNER_URL="https://github.com/Sapphire-Health/ansible-epic" \
  -e GITHUB_RUNNER_TOKEN="<registration-token>" \
  ghcr.io/sapphire-health/ansible-epic:aws  # or :azure
docker run -d `
  -p 22:22 `
  -e AUTHORIZED_KEYS="ssh-ed25519 AAAA... user@host" `
  -e GITHUB_RUNNER_URL="https://github.com/Sapphire-Health/ansible-epic" `
  -e GITHUB_RUNNER_TOKEN="<registration-token>" `
  ghcr.io/sapphire-health/ansible-epic:aws  # or :azure

To obtain a registration token from GitHub:

  1. Navigate to the repository or organization Settings.
  2. Select Actions → Runners → New self-hosted runner.
  3. Copy the token shown in the configuration step, e.g. --token ABCD1234....

Note

Runner registration tokens expire after one hour. Start the container before the token expires.

Runner Logs

By default the runner writes its output to ~/runner.log inside the container rather than stdout, keeping container logs uncluttered. To follow the log over SSH or a VS Code terminal:

tail -f ~/runner.log
Get-Content ~/runner.log -Wait

To send runner output to stdout instead (visible via docker logs), set RUNNER_DEBUG to any non-empty value:

docker run -d \
  -e GITHUB_RUNNER_URL="..." \
  -e GITHUB_RUNNER_PAT="..." \
  -e RUNNER_DEBUG="1" \
  ghcr.io/sapphire-health/ansible-epic:aws  # or :azure
docker run -d `
  -e GITHUB_RUNNER_URL="..." `
  -e GITHUB_RUNNER_PAT="..." `
  -e RUNNER_DEBUG="1" `
  ghcr.io/sapphire-health/ansible-epic:aws  # or :azure

Deregistration on Shutdown

When GITHUB_RUNNER_PAT is set, the container automatically deregisters the runner from GitHub when it stops. This keeps the runner list clean when containers are frequently rebuilt or when multiple containers are running simultaneously — each container registers with a unique name and removes itself on exit.

Note

Automatic deregistration requires GITHUB_RUNNER_PAT. If only GITHUB_RUNNER_TOKEN was supplied, the runner will appear offline in GitHub and be removed automatically after 30 days.

GitHub PAT Requirements

Repository Clone Token (GITHUB_TOKEN)

GITHUB_TOKEN is only required when ANSIBLE_REPO_URL points to a private repository. The token is used exclusively to clone the repository and is removed from the git remote URL immediately after cloning.

PAT Type Required Permission
Classic repo
Fine-grained Contents → Read

Runner PAT (GITHUB_RUNNER_PAT)

GITHUB_RUNNER_PAT is a long-lived PAT used to generate a fresh runner registration token at container startup. The container determines whether to use the repository or organization API endpoint based on the number of path segments in GITHUB_RUNNER_URL.

PAT Type Runner Scope Required Permission
Classic Repository repo
Classic Organization admin:org
Fine-grained Repository Administration → Write
Fine-grained Organization Organization self-hosted runners → Write

Publishing Images

The container images are built locally and pushed to the GitHub Container Registry (ghcr.io). Both variants are published from the same repository under the ghcr.io/sapphire-health/ansible-epic namespace.

Prerequisites

  • Git — to clone or pull the repository
  • Docker — to build and push images
    • On Windows: Docker Desktop must be installed and running in Linux container mode
  • GitHub account with write access to the Sapphire-Health/ansible-epic repository and the write:packages permission

Authenticate to GHCR

Use the GitHub CLI to authenticate Docker against ghcr.io:

gh auth token | docker login ghcr.io -u $(gh api user --jq .login) --password-stdin
gh auth token | docker login ghcr.io -u $(gh api user --jq .login) --password-stdin

If your current gh token does not have write:packages scope, re-authenticate with it:

gh auth login --scopes write:packages
gh auth token | docker login ghcr.io -u $(gh api user --jq .login) --password-stdin
gh auth login --scopes write:packages
gh auth token | docker login ghcr.io -u $(gh api user --jq .login) --password-stdin

Authenticate to ECR

Use the AWS CLI to authenticate Docker against ECR. Your AWS credentials must have ecr:GetAuthorizationToken permission.

aws ecr get-login-password --region us-west-2 | \
  docker login --username AWS --password-stdin \
  271851283454.dkr.ecr.us-west-2.amazonaws.com
aws ecr get-login-password --region us-west-2 | `
  docker login --username AWS --password-stdin `
  271851283454.dkr.ecr.us-west-2.amazonaws.com

ECR tokens expire after 12 hours. Re-run this command if Docker reports an authentication error during a push.

Build the Images

Run from the root of the repository.

Tip

Add --no-cache to any build command to force Docker to pull the latest versions of all packages and tools, ignoring the layer cache. This is useful when you want to pick up updated apt packages, a newer VS Code CLI, or a newer GitHub Actions runner without bumping the pinned version arguments. Omitting it (the default) reuses cached layers for a faster incremental build when only source files changed.

AWS image:

docker build -f Dockerfile.AWS -t ghcr.io/sapphire-health/ansible-epic:aws .
docker build -f Dockerfile.AWS -t ghcr.io/sapphire-health/ansible-epic:aws .

Azure image:

docker build -f Dockerfile.Azure -t ghcr.io/sapphire-health/ansible-epic:azure .
docker build -f Dockerfile.Azure -t ghcr.io/sapphire-health/ansible-epic:azure .

AWS Web image:

docker build -f Dockerfile.Web \
  --build-arg BASE_IMAGE=ghcr.io/sapphire-health/ansible-epic:aws \
  -t ghcr.io/sapphire-health/ansible-epic:aws-web .
docker build -f Dockerfile.Web `
  --build-arg BASE_IMAGE=ghcr.io/sapphire-health/ansible-epic:aws `
  -t ghcr.io/sapphire-health/ansible-epic:aws-web .

Azure Web image:

docker build -f Dockerfile.Web \
  --build-arg BASE_IMAGE=ghcr.io/sapphire-health/ansible-epic:azure \
  -t ghcr.io/sapphire-health/ansible-epic:azure-web .
docker build -f Dockerfile.Web `
  --build-arg BASE_IMAGE=ghcr.io/sapphire-health/ansible-epic:azure `
  -t ghcr.io/sapphire-health/ansible-epic:azure-web .

Note

The Web images layer on top of the base images. Build and push aws / azure before building the aws-web / azure-web variants, or ensure the base images are already available in the registry.

Note

The aws and azure Dockerfiles contain an ARG BUILD_DATE line near the top of the final stage. Updating this value forces Docker to invalidate the cache from that layer onward while keeping the expensive builder-stage cache warm. This is a more targeted alternative to --no-cache when you want to pick up updated apt packages or tool downloads (VS Code CLI, GitHub runner, AWS/Azure CLI).

Push the Images

docker push ghcr.io/sapphire-health/ansible-epic:aws
docker push ghcr.io/sapphire-health/ansible-epic:azure
docker push ghcr.io/sapphire-health/ansible-epic:aws-web
docker push ghcr.io/sapphire-health/ansible-epic:azure-web
docker push ghcr.io/sapphire-health/ansible-epic:aws
docker push ghcr.io/sapphire-health/ansible-epic:azure
docker push ghcr.io/sapphire-health/ansible-epic:aws-web
docker push ghcr.io/sapphire-health/ansible-epic:azure-web

Push to ECR

AWS image variants (aws, aws-coder, aws-web) can be pushed to Amazon ECR so that ECS pulls them over AWS's internal network rather than from ghcr.io. This eliminates internet egress for image pulls and significantly reduces container startup time.

Create the Repository (first time only)

aws ecr create-repository \
  --repository-name ansible-epic \
  --region us-west-2
aws ecr create-repository `
  --repository-name ansible-epic `
  --region us-west-2

Tag and Push

docker tag ghcr.io/sapphire-health/ansible-epic:aws \
  271851283454.dkr.ecr.us-west-2.amazonaws.com/ansible-epic:aws
docker push 271851283454.dkr.ecr.us-west-2.amazonaws.com/ansible-epic:aws
docker tag ghcr.io/sapphire-health/ansible-epic:aws `
  271851283454.dkr.ecr.us-west-2.amazonaws.com/ansible-epic:aws
docker push 271851283454.dkr.ecr.us-west-2.amazonaws.com/ansible-epic:aws

Repeat the tag and push for any other variants being mirrored to ECR (e.g. aws-web), substituting the tag name.

Note

No IAM changes are needed. The AmazonECSTaskExecutionRolePolicy already attached to the ECS execution role includes the permissions required to pull images from ECR (ecr:GetAuthorizationToken, ecr:BatchGetImage, etc.).

Update the Task Definition

After pushing to ECR, update the image URI in coder/dev-container/dev-container.tf and coder/gh-runner/gh-runner.tf to point to ECR instead of ghcr.io:

image = "271851283454.dkr.ecr.us-west-2.amazonaws.com/ansible-epic:aws"

Push the updated template and start a new workspace for the change to take effect.

Redeploy the AWS Container

After pushing, force ECS to pull the new image:

aws ecs update-service \
  --cluster <cluster-name> \
  --service <service-name> \
  --force-new-deployment
aws ecs update-service `
  --cluster <cluster-name> `
  --service <service-name> `
  --force-new-deployment

ECS will start a new task using the updated image and stop the old one. The runner in the old container deregisters itself during shutdown before the new container registers a replacement.

Updating the GitHub Actions Runner Version

The runner binary version is pinned in both base Dockerfiles via the GITHUB_RUNNER_VERSION build argument. To update it, change the value in both files and rebuild:

ARG GITHUB_RUNNER_VERSION=2.322.0

Find the latest release version at github.com/actions/runner/releases.

Updating the Coder Agent

The Coder agent binary is not baked into the image. At container startup, startup.sh downloads the agent directly from $CODER_AGENT_URL/bin/coder-linux-amd64, so it always matches the running Coder server version automatically. No image rebuild is required when the Coder server is upgraded.