#cloud-config # ============================================================================= # Forgejo Server Cloud-Init Configuration # Git server with Docker for Forgejo 11.0.8 LTS - FULLY AUTOMATED # ============================================================================= # NOTE: We install packages via runcmd instead of packages: section # because DigitalOcean's base image cloud-init can interfere with the packages module # bootcmd runs before write_files - create directories needed for write_files bootcmd: - mkdir -p /opt/forgejo/caddy write_files: # SSH daemon configuration (non-standard port, VPC only access expected) - path: /etc/ssh/sshd_config.d/99-hardening.conf content: | # Non-standard port (VPC access only via jump host) Port ${ssh_port} # Authentication hardening PermitRootLogin prohibit-password PasswordAuthentication no PubkeyAuthentication yes AuthenticationMethods publickey # Security settings MaxAuthTries 3 LoginGraceTime 30 PermitEmptyPasswords no X11Forwarding no AllowTcpForwarding no AllowAgentForwarding no PermitUserEnvironment no MaxSessions 3 # Session settings ClientAliveInterval 300 ClientAliveCountMax 2 # Logging LogLevel VERBOSE permissions: '0644' # NOTE: authorized_keys is managed by DigitalOcean's native SSH key provisioning # The internal key is added via digitalocean_ssh_key.internal resource # Fail2ban configuration for system SSH - path: /etc/fail2ban/jail.d/sshd.local content: | [sshd] enabled = true port = ${ssh_port} filter = sshd logpath = /var/log/auth.log backend = systemd bantime = 1h findtime = 10m maxretry = 3 permissions: '0644' # Fail2ban for Forgejo Git SSH - path: /etc/fail2ban/jail.d/forgejo-ssh.local content: | [forgejo-ssh] enabled = true port = ${git_ssh_port} filter = sshd logpath = /var/lib/docker/containers/*forgejo*/*-json.log backend = auto bantime = 1h findtime = 10m maxretry = 5 permissions: '0644' # Fail2ban filter for Forgejo HTTP auth failures - path: /etc/fail2ban/filter.d/forgejo.conf content: | [Definition] failregex = ^.*Failed authentication attempt for .* from .*$ ^.*invalid credentials from .*$ ignoreregex = permissions: '0644' # Fail2ban jail for Forgejo HTTP - path: /etc/fail2ban/jail.d/forgejo-http.local content: | [forgejo-http] enabled = true port = 80,443 filter = forgejo logpath = /var/lib/docker/containers/*forgejo*/*-json.log backend = auto bantime = 1h findtime = 10m maxretry = 5 permissions: '0644' # Docker daemon configuration (hardened) - path: /etc/docker/daemon.json content: | { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }, "storage-driver": "overlay2", "live-restore": true, "no-new-privileges": true } permissions: '0644' # UFW configuration script - path: /usr/local/bin/configure-firewall.sh content: | #!/bin/bash set -e # Reset UFW ufw --force reset # Default policies ufw default deny incoming ufw default deny outgoing # Inbound: HTTP/HTTPS for Forgejo web ufw allow in 80/tcp comment 'HTTP' ufw allow in 443/tcp comment 'HTTPS' # Inbound: Git SSH (public) ufw allow in ${git_ssh_port}/tcp comment 'Git SSH' # Inbound: VPC traffic (includes system SSH on non-standard port) ufw allow in from ${vpc_ip_range} comment 'VPC internal' # Outbound: Only necessary traffic (matching DO firewall) ufw allow out 53/tcp comment 'DNS' ufw allow out 53/udp comment 'DNS' ufw allow out 80/tcp comment 'HTTP' ufw allow out 443/tcp comment 'HTTPS' ufw allow out 123/udp comment 'NTP' ufw allow out to ${vpc_ip_range} comment 'VPC' # Enable logging for security audit ufw logging high # Enable UFW ufw --force enable echo "Firewall configured successfully" permissions: '0755' # Volume mount script - path: /usr/local/bin/mount-volume.sh content: | #!/bin/bash set -e VOLUME_NAME="${volume_name}" MOUNT_POINT="/mnt/forgejo" # Create mount point mkdir -p "$MOUNT_POINT" # Find the volume device # DigitalOcean volumes are typically at /dev/disk/by-id/scsi-0DO_Volume_* VOLUME_DEV=$(readlink -f /dev/disk/by-id/scsi-0DO_Volume_$VOLUME_NAME 2>/dev/null || true) if [ -z "$VOLUME_DEV" ]; then echo "Waiting for volume to attach..." sleep 10 VOLUME_DEV=$(readlink -f /dev/disk/by-id/scsi-0DO_Volume_$VOLUME_NAME 2>/dev/null || true) fi if [ -n "$VOLUME_DEV" ]; then # Check if already mounted if ! mountpoint -q "$MOUNT_POINT"; then mount "$VOLUME_DEV" "$MOUNT_POINT" echo "Volume mounted at $MOUNT_POINT" fi # Add to fstab if not already there if ! grep -q "$VOLUME_DEV" /etc/fstab; then echo "$VOLUME_DEV $MOUNT_POINT ext4 defaults,nofail,discard 0 2" >> /etc/fstab fi # Create subdirectories mkdir -p "$MOUNT_POINT/forgejo-data" mkdir -p "$MOUNT_POINT/forgejo-db" mkdir -p "$MOUNT_POINT/runner-data" # Set permissions (UID 1000 is typically the forgejo user in container) chown -R 1000:1000 "$MOUNT_POINT/forgejo-data" chown -R 999:999 "$MOUNT_POINT/forgejo-db" # postgres user chown -R 1000:1000 "$MOUNT_POINT/runner-data" else echo "WARNING: Volume not found. Please attach volume manually." fi permissions: '0755' # Swap configuration script (needed for 1GB RAM) - path: /usr/local/bin/configure-swap.sh content: | #!/bin/bash set -e SWAP_FILE="/swapfile" SWAP_SIZE="2G" if [ ! -f "$SWAP_FILE" ]; then echo "Creating $SWAP_SIZE swap file..." fallocate -l $SWAP_SIZE $SWAP_FILE chmod 600 $SWAP_FILE mkswap $SWAP_FILE swapon $SWAP_FILE # Add to fstab echo "$SWAP_FILE none swap sw 0 0" >> /etc/fstab # Adjust swappiness for better performance echo "vm.swappiness=10" >> /etc/sysctl.conf sysctl vm.swappiness=10 echo "Swap configured successfully" else echo "Swap file already exists" fi permissions: '0755' # Unattended upgrades configuration - path: /etc/apt/apt.conf.d/50unattended-upgrades content: | Unattended-Upgrade::Allowed-Origins { "$${distro_id}:$${distro_codename}"; "$${distro_id}:$${distro_codename}-security"; "$${distro_id}:$${distro_codename}-updates"; }; Unattended-Upgrade::AutoFixInterruptedDpkg "true"; Unattended-Upgrade::MinimalSteps "true"; Unattended-Upgrade::Remove-Unused-Dependencies "true"; Unattended-Upgrade::Automatic-Reboot "false"; permissions: '0644' # Auto-upgrades configuration - path: /etc/apt/apt.conf.d/20auto-upgrades content: | APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; APT::Periodic::AutocleanInterval "7"; permissions: '0644' # ========================================================================== # FORGEJO DOCKER COMPOSE STACK # ========================================================================== # Docker Compose file - path: /opt/forgejo/docker-compose.yml content: | services: # PostgreSQL Database forgejo-db: image: postgres:16-alpine container_name: forgejo-db restart: unless-stopped environment: POSTGRES_USER: $${POSTGRES_USER:-forgejo} POSTGRES_PASSWORD: $${POSTGRES_PASSWORD} POSTGRES_DB: $${POSTGRES_DB:-forgejo} volumes: - /mnt/forgejo/forgejo-db:/var/lib/postgresql/data networks: - forgejo-internal healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-forgejo} -d $${POSTGRES_DB:-forgejo}"] interval: 10s timeout: 5s retries: 5 deploy: resources: limits: memory: 256M reservations: memory: 128M # Forgejo Git Server forgejo: image: codeberg.org/forgejo/forgejo:11.0.8-rootless container_name: forgejo restart: unless-stopped depends_on: forgejo-db: condition: service_healthy environment: FORGEJO__database__DB_TYPE: postgres FORGEJO__database__HOST: forgejo-db:5432 FORGEJO__database__NAME: $${POSTGRES_DB:-forgejo} FORGEJO__database__USER: $${POSTGRES_USER:-forgejo} FORGEJO__database__PASSWD: $${POSTGRES_PASSWORD} FORGEJO__server__DOMAIN: $${FORGEJO_DOMAIN} FORGEJO__server__ROOT_URL: https://$${FORGEJO_DOMAIN}/ FORGEJO__server__SSH_DOMAIN: $${FORGEJO_DOMAIN} FORGEJO__server__SSH_PORT: ${git_ssh_port} FORGEJO__server__SSH_LISTEN_PORT: ${git_ssh_port} FORGEJO__server__START_SSH_SERVER: "true" FORGEJO__server__HTTP_PORT: 3000 FORGEJO__server__LFS_START_SERVER: "true" FORGEJO__security__INSTALL_LOCK: "true" FORGEJO__security__SECRET_KEY: $${FORGEJO_SECRET_KEY} FORGEJO__security__INTERNAL_TOKEN: $${FORGEJO_INTERNAL_TOKEN} FORGEJO__security__PASSWORD_COMPLEXITY: "lower,upper,digit" FORGEJO__security__MIN_PASSWORD_LENGTH: "12" FORGEJO__oauth2__JWT_SECRET: $${FORGEJO_JWT_SECRET} FORGEJO__service__DISABLE_REGISTRATION: "true" FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false" FORGEJO__service__ENABLE_NOTIFY_MAIL: "false" FORGEJO__actions__ENABLED: "true" FORGEJO__actions__DEFAULT_ACTIONS_URL: "https://code.forgejo.org" FORGEJO__repository__DEFAULT_BRANCH: "main" FORGEJO__repository__ENABLE_PUSH_CREATE_USER: "true" FORGEJO__repository__ENABLE_PUSH_CREATE_ORG: "true" FORGEJO__lfs__PATH: /data/lfs FORGEJO__webhook__ALLOWED_HOST_LIST: "private" FORGEJO__webhook__SKIP_TLS_VERIFY: "false" FORGEJO__log__MODE: "console" FORGEJO__log__LEVEL: "Info" volumes: - /mnt/forgejo/forgejo-data:/data - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro networks: - forgejo-internal - forgejo-public ports: - "${git_ssh_port}:${git_ssh_port}" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"] interval: 30s timeout: 10s retries: 3 start_period: 60s security_opt: - no-new-privileges:true deploy: resources: limits: memory: 384M reservations: memory: 192M # Caddy Reverse Proxy with auto-SSL and rate limiting caddy: build: context: ./caddy dockerfile: Dockerfile container_name: forgejo-caddy restart: unless-stopped depends_on: forgejo: condition: service_healthy ports: - "80:80" - "443:443" volumes: - /opt/forgejo/Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config networks: - forgejo-public environment: FORGEJO_DOMAIN: $${FORGEJO_DOMAIN} security_opt: - no-new-privileges:true deploy: resources: limits: memory: 64M reservations: memory: 32M # Forgejo Actions Runner (CI/CD) forgejo-runner: image: code.forgejo.org/forgejo/runner:6.3.1 container_name: forgejo-runner restart: unless-stopped depends_on: forgejo: condition: service_healthy docker-dind: condition: service_started environment: DOCKER_HOST: tcp://docker-dind:2376 DOCKER_TLS_VERIFY: "1" DOCKER_CERT_PATH: /certs/client volumes: - /mnt/forgejo/runner-data:/data - dind-certs-client:/certs/client:ro networks: - forgejo-internal - dind-network command: > sh -c ' if [ ! -f /data/.runner ]; then echo "Runner not registered. Run: docker compose exec forgejo-runner forgejo-runner register" sleep infinity fi forgejo-runner daemon --config /data/config.yaml ' deploy: resources: limits: memory: 256M reservations: memory: 128M # Docker-in-Docker for Runner (builds images in CI/CD) docker-dind: image: docker:27-dind container_name: forgejo-dind restart: unless-stopped privileged: true environment: DOCKER_TLS_CERTDIR: /certs volumes: - dind-certs-ca:/certs/ca - dind-certs-client:/certs/client - dind-storage:/var/lib/docker networks: - dind-network deploy: resources: limits: memory: 512M reservations: memory: 256M networks: forgejo-internal: driver: bridge internal: true forgejo-public: driver: bridge dind-network: driver: bridge volumes: caddy_data: caddy_config: dind-certs-ca: dind-certs-client: dind-storage: permissions: '0644' # Caddy Dockerfile with rate-limit plugin - path: /opt/forgejo/caddy/Dockerfile content: | FROM caddy:2-builder AS builder RUN xcaddy build --with github.com/mholt/caddy-ratelimit FROM caddy:2-alpine COPY --from=builder /usr/bin/caddy /usr/bin/caddy permissions: '0644' # Caddyfile for reverse proxy with rate limiting - path: /opt/forgejo/Caddyfile content: | { order rate_limit before basicauth } ${domain} { # Rate limiting - 100 requests per minute per IP rate_limit { zone forgejo_zone { key {remote_host} events 100 window 1m } } reverse_proxy forgejo:3000 encode gzip zstd header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Frame-Options "SAMEORIGIN" X-Content-Type-Options "nosniff" X-XSS-Protection "1; mode=block" Referrer-Policy "strict-origin-when-cross-origin" -Server } log { output file /data/access.log { roll_size 10mb roll_keep 5 } } } permissions: '0644' # Script to create .env with Terraform-generated secrets - path: /usr/local/bin/setup-forgejo.sh content: | #!/bin/bash set -e ENV_FILE="/opt/forgejo/.env" # Only create if .env doesn't exist (preserves on re-run) if [ ! -f "$ENV_FILE" ]; then echo "Writing Forgejo configuration..." { echo "# Forgejo configuration (secrets from Terraform)" echo "FORGEJO_DOMAIN=${domain}" echo "" echo "# PostgreSQL" echo "POSTGRES_USER=forgejo" echo "POSTGRES_PASSWORD=${postgres_password}" echo "POSTGRES_DB=forgejo" echo "" echo "# Forgejo Security Keys" echo "FORGEJO_SECRET_KEY=${forgejo_secret_key}" echo "FORGEJO_INTERNAL_TOKEN=${forgejo_internal_token}" echo "FORGEJO_JWT_SECRET=${forgejo_jwt_secret}" } > "$ENV_FILE" chmod 600 "$ENV_FILE" echo "Configuration saved to $ENV_FILE" else echo ".env already exists, skipping" fi permissions: '0755' # DNS wait script - waits for DNS to propagate before starting Caddy - path: /usr/local/bin/wait-for-dns.sh content: | #!/bin/bash DOMAIN="${domain}" MAX_ATTEMPTS=60 ATTEMPT=0 echo "Waiting for DNS to propagate for $DOMAIN..." while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do if host "$DOMAIN" > /dev/null 2>&1; then echo "DNS resolved for $DOMAIN" exit 0 fi ATTEMPT=$((ATTEMPT + 1)) echo "Attempt $ATTEMPT/$MAX_ATTEMPTS - waiting 10s..." sleep 10 done echo "WARNING: DNS did not resolve after $MAX_ATTEMPTS attempts" echo "Let's Encrypt certificate may fail - run 'docker compose restart caddy' after DNS propagates" exit 0 permissions: '0755' # Message of the day - path: /etc/motd content: | +---------------------------------------------------------------+ | FORGEJO GIT SERVER | | | | Domain: ${domain} | Git SSH Port: ${git_ssh_port} | | | Data location: /mnt/forgejo | | Docker compose: /opt/forgejo | | | | Commands: | | cd /opt/forgejo && docker compose logs -f | | cd /opt/forgejo && docker compose restart | | | | Runner Registration: | | 1. Get token from Forgejo: Site Admin > Actions > Runners | | 2. Register runner: | | cd /opt/forgejo && docker compose exec forgejo-runner \ | | forgejo-runner register --instance https://${domain} | | --token YOUR_TOKEN --name realms-runner | | --labels ubuntu-latest,docker | | 3. Restart: docker compose restart forgejo-runner | | | +---------------------------------------------------------------+ permissions: '0644' runcmd: # Ensure .ssh directory exists with correct permissions # (authorized_keys is managed by DigitalOcean's native SSH key provisioning) - mkdir -p /root/.ssh && chmod 700 /root/.ssh # Ensure sshd_config includes the .d directory (Debian 12 fix) - grep -q 'Include /etc/ssh/sshd_config.d' /etc/ssh/sshd_config || sed -i '1i Include /etc/ssh/sshd_config.d/*.conf' /etc/ssh/sshd_config # Comment out Port in main sshd_config so sshd_config.d takes precedence (Debian 12 fix) - sed -i 's/^Port /#Port /' /etc/ssh/sshd_config # Restart SSH with new configuration - systemctl restart sshd # Wait for any background apt processes to finish (DO images run apt on boot) - while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do echo "Waiting for dpkg lock..."; sleep 5; done # Install prerequisites - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=60 install -y ca-certificates curl gnupg fail2ban ufw unattended-upgrades apt-listchanges vim git # Add Docker's official GPG key and repository - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg - chmod a+r /etc/apt/keyrings/docker.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list # Install Docker from official repository - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=60 install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # Enable and start Docker - systemctl enable docker - systemctl start docker # Enable and start fail2ban - systemctl enable fail2ban - systemctl restart fail2ban # Configure swap (important for 1GB RAM) - /usr/local/bin/configure-swap.sh # Mount the volume - /usr/local/bin/mount-volume.sh # Fix ownership for Forgejo container (runs as UID 1000) # This must run AFTER all directories are created to ensure correct permissions - chown -R 1000:1000 /mnt/forgejo/forgejo-data # Configure firewall - /usr/local/bin/configure-firewall.sh # Generate secrets and create .env - /usr/local/bin/setup-forgejo.sh # Wait for DNS to propagate before starting Caddy (for Let's Encrypt) - /usr/local/bin/wait-for-dns.sh # Start Forgejo stack (build Caddy with rate-limit plugin, pull others) - cd /opt/forgejo && docker compose build caddy - cd /opt/forgejo && docker compose pull forgejo forgejo-db forgejo-runner docker-dind - cd /opt/forgejo && docker compose up -d # Enable unattended upgrades - systemctl enable unattended-upgrades - systemctl start unattended-upgrades final_message: "Forgejo server fully deployed after $UPTIME seconds. Visit https://${domain}"