The Problem: Two Errors, One Root Cause

When deploying applications via GitHub Actions using appleboy/ssh-action to pull images from GitHub Container Registry (GHCR), you'll likely encounter one or both of these errors:

Error response from daemon: Head "https://ghcr.io/v2/ORG/REPO/manifests/latest": unauthorized
Error: Cannot perform an interactive login from a non TTY device

These appear to be separate issues, but they share the same cause: Docker authentication is not correctly handled in a non-interactive environment (CI/CD or SSH automation).

Root Cause: Interactive vs. Non-Interactive Login

Docker's docker login command by default expects a terminal (TTY) to prompt for username and password. In GitHub Actions and SSH scripts, there is no TTY — no keyboard input, no interactive prompt. So:

Both mean the Docker daemon on the remote server lacks valid credentials to pull from GHCR.

The Fix: Non-Interactive Login with --password-stdin

The correct approach for CI/CD is to pass the password via stdin:

echo $GHCR_TOKEN | docker login ghcr.io -u $GHCR_USERNAME --password-stdin

This avoids the TTY requirement entirely.

Step-by-Step Implementation

1. Create a GitHub Personal Access Token (PAT)

Go to GitHub → Settings → Developer Settings → Personal Access Tokens. Generate a token with:

2. Store Secrets in GitHub Actions

Add the following secrets to your GitHub repository:

3. Pass Secrets into SSH Action

In your workflow YAML, use envs to inject secrets into the SSH session:

- name: Deploy via SSH
  uses: appleboy/ssh-action@v1.2.5
  with:
    host: ${{ secrets.HOST }}
    username: ${{ secrets.USER }}
    key: ${{ secrets.SSH_KEY }}
    envs: GHCR_USERNAME,GHCR_TOKEN
    script: |
      echo $GHCR_TOKEN | docker login ghcr.io -u $GHCR_USERNAME --password-stdin
      docker compose pull
      docker compose up -d

4. Verify Login on Server

After a successful deployment, you can check ~/.docker/config.json on the server. It should contain an entry like:

{
  "auths": {
    "ghcr.io": {
      "auth": "..."
    }
  }
}

Complete Deployment Script

Here's a robust script for your SSH action:

set -e
echo "Logging into GHCR..."
echo $GHCR_TOKEN | docker login ghcr.io -u $GHCR_USERNAME --password-stdin
echo "Pulling latest images..."
docker compose pull
echo "Restarting containers..."
docker compose up -d
echo "Cleaning unused images..."
docker system prune -f

Common Mistakes

  1. Using interactive logindocker login ghcr.io will always fail in non-TTY environments.
  2. Forgetting envs — If you don't pass GHCR_TOKEN and GHCR_USERNAME via envs, the SSH session receives empty values, causing silent login failure.
  3. Wrong image name/tag — Ensure the image exists at ghcr.io/org/repo:latest (or your specific tag).
  4. Missing read:packages — Without this scope, GHCR returns unauthorized regardless of login.

Why This Only Happens in DevOps Automation

This pattern is ubiquitous in:

All of these are non-interactive environments where no human input is available.

Summary

Remember: GHCR pull failures in CI/CD are always authentication problems. The two errors (unauthorized and non-TTY) are two sides of the same coin. Use --password-stdin, pass secrets via envs, and ensure your PAT has read:packages. Avoid interactive commands in automation.

Final Checklist