The Definitive Guide to Docker-in-Docker with Gitea Actions
It has come to my attention that someone is wrong on the Internet. Actually, not just someone—seemingly everyone writing guides about setting up Docker-in-Docker (DIND) with Gitea Actions. The multitudes of tutorials, blog posts, and StackOverflow answers all seem to miss critical architectural limitations and security considerations that make their solutions either incomplete, insecure, or simply non-functional in real-world scenarios.
This guide presents a complete, almost production-ready example for isolated Docker-in-Docker CI/CD using Gitea Actions with proper security boundaries and full functionality.
You can find all the examples mentioned here in this repository on GitHub
⚠️ Enterprise Security Warning
This setup is intended for development and testing environments. For true enterprise-grade production deployments, additional security measures are required:
Critical Security Enhancements Needed for Enterprise-grade Production
-
Network Firewall Protection
- Deploy firewall rules to isolate the CI/CD network from internal corporate networks
- Implement egress filtering to prevent build containers from accessing internal services
- Use network segmentation to contain potential container breakouts
- Consider running the entire stack in a separate VLAN or VPC
-
Container Image Security
- Gitea needs a feature that only white listed images are allowed to run in priveleged-mode
- Only allow pre-approved, security-scanned base images
- Implement image signing and verification workflows
- Regular vulnerability scanning of all container images
-
DIND Image Hardening
- Remove unnecessary packages and tools from the custom DIND image
- Implement read-only root filesystem where possible
- Use distroless or minimal base images
Plenty more with various compliance stuff but the above state is a good start.
The configuration presented here prioritizes functionality and ease of setup over maximum security hardening.
Requirements & Use Case
We need a CI/CD environment that provides:
- Complete isolation from the host Docker daemon
- Docker functionality available to both services and build steps
- Self-contained deployment with no external dependencies
- Proper security boundaries between jobs and the host system
- Full Docker API access for build, test, and deployment workflows
The Problem: Gitea Actions Limitations
Gitea's act_runner
is based on the excellent nektos/act
project, but it has several critical limitations when compared to GitHub Actions:
1. Incomplete Services Support
-
Incomplete
volumes
mounting capability for services in workflow YAML -
Limited
options
support compared to fulldocker run
functionality -
No
command
override support in theservices:
section
2. Docker Configuration Challenges
- Cannot mount
daemon.json
configuration files into service containers - No way to inject custom Docker daemon startup parameters
- Services are treated as immutable "black boxes"
3. GitHub Actions Parity Issues
GitHub Actions itself doesn't support:
-
command
overrides in theservices:
section
Why Custom DIND Images Are Required
Standard docker:dind
images:
- Listen on Unix socket by default (
/var/run/docker.sock
) - Have TLS enabled by default (requires certificates)
- Cannot be configured via environment variables for network settings
- Cannot be customized through workflow YAML due to services limitations
Our solution: Build a custom DIND image with hardcoded configuration:
FROM docker:dind
CMD ["dockerd", "--host", "tcp://0.0.0.0:2376", "--tls=false"]
Architecture Overview: Triple-Nested Isolation
Our architecture provides three layers of Docker isolation:
- Host Docker (docker-compose level) - Orchestrates the entire CI/CD stack
- Runner DIND (act_runner execution environment) - Provides Docker services for workflows
- Build DIND (workflow build steps) - Enables Docker operations within build containers
This triple nesting is essential because:
- Services run in the runner's Docker daemon
- Build steps run inside containers with no Docker daemon access
- Each layer provides different security boundaries and functional contexts
Step-by-Step Implementation
Step 1: Create the Custom DIND Image
Dockerfile:
FROM docker:dind
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD docker version || exit 1
CMD ["dockerd", "--host", "tcp://0.0.0.0:2376", "--tls=false"]
Step 2: Configure Gitea for Actions
⚠️ Security Note: The
INTERNAL_TOKEN
andJWT_SECRET
values in the exampleapp.ini
below are provided for convenience to make this docker-compose example work out-of-the-box. In production deployments, these tokens MUST be regenerated using fresh, cryptographically secure random values. Never use these example tokens in any environment beyond local development and testing.
app.ini:
APP_NAME = Gitea: Git with a cup of tea
RUN_MODE = prod
WORK_PATH = /data/gitea
[actions]
ENABLED = true
[repository]
ROOT = /data/git/repositories
[repository.local]
LOCAL_COPY_PATH = /data/gitea/tmp/local-repo
[repository.upload]
TEMP_PATH = /data/gitea/uploads
[server]
APP_DATA_PATH = /data/gitea
DOMAIN = localhost
SSH_DOMAIN = localhost
HTTP_PORT = 3000
ROOT_URL = http://localhost:3000
LOCAL_ROOT_URL= http://gitea:3000
DISABLE_SSH = false
SSH_PORT = 2222
SSH_LISTEN_PORT = 22
LFS_START_SERVER = false
[database]
PATH = /data/gitea/gitea.db
DB_TYPE = sqlite3
HOST = localhost:3306
NAME = gitea
USER = root
PASSWD =
LOG_SQL = false
[indexer]
ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve
[session]
PROVIDER_CONFIG = /data/gitea/sessions
[picture]
AVATAR_UPLOAD_PATH = /data/gitea/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
[attachment]
PATH = /data/gitea/attachments
[log]
MODE = console
LEVEL = info
ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = true
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NTY0NzI0OTF9.lXfJEgeQCkXcQx3VKm-TwLQktTYrccm_JK1P0xiDmEw
[service]
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
[lfs]
PATH = /data/git/lfs
[oauth2]
JWT_SECRET = nq7Fpd5bAPFHOWHZJJah2rKfXdC3pKaF0pMgtaQwAdw
Step 3: Docker Compose Configuration
The complete docker-compose.yml orchestrates six services with proper dependency management:
docker-compose.yml:
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
ports:
- "3000:3000"
- "2222:22"
volumes:
- gitea_data:/data
- ./app.ini:/data/gitea/conf/app.ini:ro
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
- USER_UID=1000
- USER_GID=1000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
dind:
build: .
container_name: dind
privileged: true
volumes:
- dind_data:/var/lib/docker
environment:
- DOCKER_HOST=tcp://localhost:2376
healthcheck:
test: ["CMD", "docker", "info"]
interval: 10s
timeout: 10s
retries: 5
start_period: 30s
restart: unless-stopped
registry:
image: registry:2
container_name: registry
volumes:
- registry_data:/var/lib/registry
environment:
- REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY=/var/lib/registry
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:5000/v2/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
restart: unless-stopped
image-builder:
image: docker:dind
container_name: image-builder
depends_on:
registry:
condition: service_healthy
dind:
condition: service_healthy
volumes:
- ./Dockerfile:/workspace/Dockerfile
- dind_data:/var/lib/docker
working_dir: /workspace
command: >
sh -c "
export DOCKER_HOST=tcp://dind:2376
export DOCKER_TLS_VERIFY=\"\"
echo 'Building and pushing DIND image to local registry...'
docker build -t registry:5000/dind-plain:latest .
docker push registry:5000/dind-plain:latest
echo 'Image build and push complete!'
"
restart: "no"
runner-configurator:
image: gitea/gitea:latest
container_name: runner-configurator
depends_on:
gitea:
condition: service_healthy
volumes:
- runner_config:/config
- ./app.ini:/data/gitea/conf/app.ini:ro
command: >
sh -c "
echo 'Gitea is healthy, proceeding...'
echo 'Generating runner token...'
su - git -c 'cd /data && /usr/local/bin/gitea actions generate-runner-token' > /config/token
echo 'Creating runner config...'
GITEA_IP=$$(getent hosts gitea | awk '{print $$1}')
REGISTRY_IP=$$(getent hosts registry | awk '{print $$1}')
echo \"Resolved Gitea IP: $$GITEA_IP\"
cat > /config/config.yaml << EOF
log:
level: info
runner:
file: .runner
capacity: 1
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
labels:
- 'ubuntu-latest:docker://gitea/runner-images:ubuntu-latest'
- 'ubuntu-22.04:docker://gitea/runner-images:ubuntu-22.04'
- 'ubuntu-20.04:docker://gitea/runner-images:ubuntu-20.04'
- 'linux:docker://gitea/runner-images:ubuntu-latest'
cache:
enabled: true
dir: '/tmp/cache'
host: ''
port: 0
container:
network_mode: bridge
enable_ipv6: false
privileged: true
valid_volumes:
- '**'
docker_host: tcp://dind:2376
options: '--add-host=gitea:$$GITEA_IP --add-host=registry:$$REGISTRY_IP'
host:
workdir_parent: /tmp
EOF
echo 'Configuration complete!'
"
restart: "no"
admin-setup:
image: gitea/gitea:latest
container_name: admin-setup
depends_on:
gitea:
condition: service_healthy
environment:
- GITEA_URL=http://gitea:3000
volumes:
- gitea_data:/data
- ./app.ini:/data/gitea/conf/app.ini:ro
command: >
sh -c "
echo 'Creating admin user...'
su - git -c '/usr/local/bin/gitea admin user create --admin --username admin --password admin --email admin@localhost.local --must-change-password=false' || echo 'Admin user already exists'
echo 'Admin setup complete!'
"
restart: "no"
runner:
image: gitea/act_runner:latest
container_name: runner
depends_on:
gitea:
condition: service_healthy
dind:
condition: service_healthy
admin-setup:
condition: service_completed_successfully
runner-configurator:
condition: service_completed_successfully
image-builder:
condition: service_completed_successfully
volumes:
- runner_config:/config:ro
- runner_data:/data
environment:
- DOCKER_HOST=tcp://dind:2376
- DOCKER_TLS_VERIFY=""
- GITEA_INSTANCE_URL=http://gitea:3000
- GITEA_RUNNER_REGISTRATION_TOKEN_FILE=/config/token
- CONFIG_FILE=/config/config.yaml
command: >
sh -c "
echo 'All dependencies ready, token file available...'
echo 'Registering and starting runner...'
act_runner register --config /config/config.yaml --no-interactive
act_runner daemon --config /config/config.yaml
"
restart: unless-stopped
volumes:
gitea_data:
dind_data:
runner_config:
runner_data:
registry_data:
Step 4: Example Workflow
.gitea/workflows/test-dind.yml:
name: Test DIND Integration
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
jobs:
test-docker:
runs-on: linux
services:
docker:
image: registry:5000/dind-plain:latest
env:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_VERIFY: ""
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Wait
run: sleep 10
- name: Verify Docker daemon is running
run: |
echo "Testing Docker daemon connection..."
docker info
- name: List running containers
run: |
echo "Listing all containers..."
docker ps -a
- name: Test Docker functionality
run: |
echo "Testing basic Docker operations..."
docker run --rm hello-world
- name: Verify DIND isolation
run: |
echo "Testing container isolation..."
docker run --rm alpine:latest echo "DIND is working perfectly!"
Security Considerations
This architecture provides multiple security boundaries:
- Host Isolation: Docker-compose isolates the entire CI/CD stack from the host
- Runner Isolation: Each workflow job gets its own Docker environment
- Build Isolation: Docker operations in build steps use separate DIND instances
- Network Isolation: Partial. Services and builds cannot directly access host resources but can access host network.
Conclusion
This guide provides a complete examplt of a production-ready solution for Docker-in-Docker with Gitea Actions. The architecture addresses the fundamental limitations of both GitHub Actions and Gitea's act_runner while providing proper security isolation and full Docker functionality.
The key insights that most guides miss:
- Custom DIND images are required due to services configuration limitations
- Triple-nested isolation provides both security and functionality
Top comments (0)