When you manage Docker stacks through Portainer's git integration, the temptation for CLI deploys is to SSH into the server and run docker compose up -d --build directly. Don't. That bypasses Portainer entirely — it won't know about your changes, env vars get out of sync, and you end up with a stack that looks fine in the terminal but wrong in the Portainer UI.

The better approach: use Portainer's own API. The /api/stacks/{id}/git/redeploy endpoint does exactly what the "Redeploy" button in the UI does — pulls the repo, rebuilds the image, updates env vars, restarts the container. Portainer stays the single source of truth.

Here's the core of a deploy script that runs locally and triggers Portainer remotely:

bash
GIT_HASH=$(git rev-parse --short HEAD)

curl -s -X PUT "$PORTAINER_URL/api/stacks/$STACK_ID/git/redeploy?endpointId=$ENDPOINT_ID" \
  -H "X-API-Key: $PORTAINER_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"env\": [
      {\"name\": \"GIT_HASH\", \"value\": \"$GIT_HASH\"}
    ],
    \"pullImage\": true,
    \"repositoryAuthentication\": true,
    \"repositoryUsername\": \"$GITHUB_USER\",
    \"repositoryPassword\": \"$GITHUB_TOKEN\"
  }"

Key things I learned setting this up: private repos need repositoryAuthentication: true with a GitHub PAT (the UI handles this silently but the API won't). Env vars in the request body replace the full set, so include all your existing vars — not just the ones you're changing. And generate a dedicated Portainer API token under Settings → Access tokens rather than using your login credentials.

No files on the server outside Docker. No SSH. Portainer sees every deploy. Works from any machine with the API key.