A containerized webhook system for automated CI/CD deployments based on the adnanh/webhook server.
This tool provides a complete Docker-based solution for automated deployment of Git repositories via webhooks. It uses Traefik as a reverse proxy and supports SSL termination with Let's Encrypt.
- 🐳 Fully containerized using Docker and Docker Compose
- 🔒 Secure webhook authentication via API keys
- 📊 Deployment status tracking
- 📝 Detailed logs for each deployment
- 🔄 Automatic Git updates with configurable branches
- 🚀 Traefik integration with SSL support
- ⚙️ Template-based configuration for multiple environments
The system is split into two containers with different trust levels:
webhook(public, reachable via Traefik) — validates the API key and drops a job file into a shared queue. It holds nodocker.sock, no SSH keys, and cannot readrepos.json.deploy-agent(private, no published port, not on theproxynetwork) — watches the queue, updates the Git checkout and runs the repo's own deploy script (which typically runsdocker compose build/upfor that project). This is the only container withdocker.sockand SSH access.
Both containers only share filesystem volumes (queue + status dir) — there is
no network path from webhook to deploy-agent, so compromising the public
HTTP endpoint alone does not grant Docker or Git access.
ci-webhooks/
├── docker-compose.yml # Main configuration (webhook + deploy-agent)
├── Dockerfile # webhook (control plane) container
├── entrypoint.sh # webhook container entrypoint script
├── start.sh # Startup script
├── ci/
│ ├── hooks.tpl.json # Webhook configuration template
│ ├── trigger-deploy.sh # Validates request, enqueues a deploy job
│ ├── status.sh # Status checker (reads status dir)
│ ├── _deploy.sh # Actual deployment worker (runs in deploy-agent)
│ └── agent/
│ ├── Dockerfile # deploy-agent container
│ └── watch.sh # Queue watcher, calls _deploy.sh per job
├── config/
│ ├── environment.tpl.json # Environment variables template
│ └── repos.tpl.json # Repository configuration template
└── startup/
├── Dockerfile # Init container for configuration
└── generate_env.sh # .env file generator- Docker & Docker Compose
- Running Traefik reverse proxy
- Git repository with SSH access
git clone git@github.com:temmiland/ci-webhooks.git ci-webhooks
cd ci-webhooksCopy and edit the template files:
cp config/environment.tpl.json config/environment.json
cp config/repos.tpl.json config/repos.jsonEdit config/environment.json:
{
"network": "proxy",
"ci_port": 9001,
"ci_domain": "ci.yourdomain.com",
"projects_root": "/srv/projects",
"ci_key": "null"
}If ci_key is null, it will be generated automatically.
Edit config/repos.json:
{
"repos": [
{
"name": "my-app",
"path": "/srv/projects/my-app",
"branch": "main",
"deploy_script": "deploy.sh"
},
{
"name": "api-backend",
"path": "/srv/projects/api-backend",
"branch": "production",
"deploy_script": "scripts/deploy.sh"
}
]
}Ensure SSH keys for Git access are available and mounted into the container:
# SSH keys should be available at:
~/.ssh/id_rsa
~/.ssh/id_rsa.pub
~/.gitconfig./start.shThe system provides two main endpoints:
The API key is sent as a header, not a URL parameter, so it never ends up in
server access logs, browser history, or Referer headers.
POST https://ci.yourdomain.com/hooks/deploy-project?repo=REPO_NAME
X-Api-Key: YOUR_CI_KEY
This only enqueues the deploy — it returns immediately with "status":"queued".
Poll the status endpoint to see when the deploy-agent container has picked it
up and finished.
GET https://ci.yourdomain.com/hooks/status-project?repo=REPO_NAME
X-Api-Key: YOUR_CI_KEY
Each repository to be automatically updated requires a deployment script:
#!/bin/bash
# deploy.sh
echo "Starting deployment..."
# Install dependencies
npm install --production
# Build application
npm run build
# Restart services
docker-compose down
docker-compose up -d
echo "Deployment completed successfully"Make the script executable:
chmod +x deploy.shThe path can be defined in the deploy_script property in repos.json. Use a relative path from the repo root.
| Variable | Description | Example |
|---|---|---|
CI_DOMAIN |
Domain for webhook server | ci.example.com |
CI_PORT |
Port for webhook server | 9001 |
CI_KEY |
Authentication key | secret-key-123 |
PROJECTS_ROOT |
Path to projects | /srv/projects |
TRAEFIK_NETWORK_NAME |
Traefik network | proxy |
PROJECTS_ROOT is mounted into the deploy-agent container at the same
path as on the host, so the path entries in repos.json are plain host
paths. This also keeps bind mounts in your projects' own compose files
working (docker-outside-of-docker: the host daemon resolves those paths).
curl -X POST "https://ci.yourdomain.com/hooks/deploy-project?repo=my-app" \
-H "X-Api-Key: YOUR_CI_KEY"name: Deploy via Webhook
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Trigger deployment
run: |
DEPLOY_RESPONSE=$(curl -s -X POST "https://ci.yourdomain.com/hooks/deploy-project?repo=my-app" \
-H "X-Api-Key: ${{ secrets.CI_KEY }}")
echo "Deploy response: $DEPLOY_RESPONSE"
- name: Wait for deployment status
run: |
MAX_RETRIES=60
SLEEP=5
COUNT=0
while true; do
STATUS_RESPONSE=$(curl -s -X GET "https://ci.yourdomain.com/hooks/status-project?repo=my-app" \
-H "X-Api-Key: ${{ secrets.CI_KEY }}")
if echo "$STATUS_RESPONSE" | jq empty >/dev/null 2>&1; then
STATUS=$(echo "$STATUS_RESPONSE" | jq -r '.status')
echo "Status: $STATUS_RESPONSE"
else
echo "⚠️ $STATUS_RESPONSE"
STATUS="unknown"
fi
if [[ "$STATUS" == "success" ]]; then
echo "✅ Deployment successful!"
exit 0
elif [[ "$STATUS" == "error" || "$STATUS" == "failed" ]]; then
echo "❌ Deployment failed!"
exit 1
fi
COUNT=$((COUNT+1))
if [[ $COUNT -ge $MAX_RETRIES ]]; then
echo "⏱ Timeout: Deployment took too long."
exit 1
fi
sleep $SLEEP
doneDeployment queued (returned immediately by deploy-project; the
deploy-agent container has not necessarily picked it up yet):
{
"status": "queued",
"message": "Deployment queued for my-app"
}Deployment running:
{
"status": "running",
"message": "Deployment is running"
}Deployment successful:
{
"status": "success",
"message": "Deployment successful"
}Deployment failed:
{
"status": "error",
"message": "Deployment failed"
}# HTTP-facing webhook server logs (request handling, auth)
docker logs ci-web
# Deploy-agent logs (queue processing, git, docker compose output)
docker logs ci-deploy-agent
# Deployment logs per repository per day (held by the deploy-agent container)
# — one file per calendar day, e.g. deploy_my-app_2026_07_13.log
docker exec ci-deploy-agent cat /opt/webhook/logs/deploy_my-app_2026_07_13.log
# Today's log, without knowing the exact date
docker exec ci-deploy-agent sh -c 'cat /opt/webhook/logs/deploy_my-app_$(date +%Y_%m_%d).log'
# List all deployment logs
docker exec ci-deploy-agent ls -la /opt/webhook/logs/{"status":"error","message":"Repo my-app not found in repos.json"}This is written by _deploy.sh running inside ci-deploy-agent, so it shows
up when you poll the status endpoint (deploy-project itself always
returns "status":"queued" immediately, regardless of whether the repo
exists).
Solution: Check config/repos.json and ensure the repository is configured.
Deployment script deploy.sh not found or not executableSolution:
chmod +x /path/to/repo/deploy.shSSH keys and docker.sock are only mounted into ci-deploy-agent, not ci-web.
Solution: Check SSH keys and Git configuration inside the agent container:
docker exec ci-deploy-agent ssh -T git@github.com
docker exec ci-deploy-agent git config --listSolution: Check Traefik labels in docker-compose.yml and ensure the proxy network exists.
Note that ci-deploy-agent is intentionally not on the proxy network and has no Traefik labels.
Solution: Check that the ci-deploy-agent container is running and watching the queue:
docker logs ci-deploy-agent
docker exec ci-web ls -la /opt/webhook/queue/The ci-web-secure Traefik router has a rate limit (10 req/min, burst 5) to
blunt brute-force/abuse against the API key. If a legitimate integration hits
this, raise ci-web-ratelimit.ratelimit.average/burst in docker-compose.yml.
-verbose is intentionally left off the default webhook command since it can
log request headers (including X-Api-Key) to docker logs. To debug
temporarily, add -verbose back to the command: of the webhook service in
docker-compose.yml, redeploy, and remove it again afterwards.
ci-deploy-agent kills a deploy after DEPLOY_TIMEOUT seconds (default
120) so a hung git fetch or deploy script can't block the queue forever.
If your deploy script legitimately runs longer (e.g. a slow docker compose build), raise it by setting DEPLOY_TIMEOUT (seconds) as an environment
variable on the deploy-agent service in docker-compose.yml.
Each repo gets one log file per calendar day
(deploy_<repo>_<year>_<month>_<day>.log). ci-deploy-agent deletes those
files once they're older than LOG_RETENTION_DAYS (default 7). Raise it via
the LOG_RETENTION_DAYS environment variable on the deploy-agent service if
you need longer history.
# Webhook server logs (auth, request handling)
docker logs -f ci-web
# Deploy agent logs (queue processing, git, deploy script output)
docker logs -f ci-deploy-agent
# Deployment status
docker exec ci-deploy-agent find /opt/webhook/status -name "*.json" -exec cat {} \;
# Deployment logs
docker exec ci-deploy-agent find /opt/webhook/logs -name "*.log" -exec tail -n 20 {} \;- Use strong CI keys:
openssl rand -hex 32- Keep containers updated:
docker compose pull
docker compose up -d-
Trust boundary: only
ci-deploy-agentholdsdocker.sockand SSH keys; it is not published and has no Traefik labels, so it should never be reachable directly from the internet. Don't addports:or Traefik labels to that service, and don't movedocker.sock/SSH mounts back ontoci-web. -
Repo trust: anything in a repo's
deploy_scriptruns with fulldocker.sockaccess viaci-deploy-agent. Only add repositories toconfig/repos.jsonwhose branch and deploy script you trust — this is equivalent to giving push access to that branch root-level Docker control on the host. -
ci-webruns hardened: thewebhookservice is started withread_only: true,cap_drop: [ALL]andno-new-privileges:trueindocker-compose.yml— it never needs to write outside the mounted queue dir or escalate privileges. -
Use a dedicated deploy key instead of your full
~/.ssh: by defaultdocker-compose.ymlmounts your entire~/.sshread-only intoci-deploy-agent, which means everydeploy_scriptin every repo listed inrepos.jsoncan read all your private keys, not just the one needed for that repo. On a production host, replace it with a key generated just for this purpose:ssh-keygen -t ed25519 -f ~/.ssh-ci-webhooks/id_ed25519 -N "" -C "ci-webhooks-deploy" # add ~/.ssh-ci-webhooks/id_ed25519.pub as a read-only deploy key on each # Git host/repo that ci-webhooks needs to pull, and copy your existing # ~/.ssh/config entries for those hosts into ~/.ssh-ci-webhooks/config if needed
Then point the bind mount at that directory instead of your real
~/.sshindocker-compose.yml:- ~/.ssh-ci-webhooks:/root/.ssh:roThis is a manual step on purpose — it touches host SSH configuration on a production system, so it isn't applied automatically.
See docs/security-architecture-review.md for the full review and remaining open items.
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License - see LICENSE.md for details.
- adnanh/webhook for the awesome webhook server
- Traefik for the reverse proxy
- Docker Community for the container ecosystem
If you like this project and want to support it:
- ⭐ Star it on GitHub
- 🔄 Share it with friends or colleagues
- 🐞 Report issues or suggest features
- 💡 Contribute code or improvements
