Migrating Forgejo from Bare Metal to Docker

I successfully migrated my Forgejo instance from a bare metal Debian 12 server to a Docker container setup with MySQL. I started out by creating a local container environment in which to run the migration. I then moved it on to my production Docker host.

Prerequisites

  • SQL dump of your existing database
  • Backup of repository data directory
  • Docker and docker-compose installed
  • At least 2x current data size in disk space

Docker Configuration

docker-compose.yml

services:
  server:
    image: codeberg.org/forgejo/forgejo:14
    container_name: forgejo
    environment:
      # User/Group IDs for volume permissions
      - USER_UID=1000
      - USER_GID=1000
      # Database Configuration
      - FORGEJO__database__DB_TYPE=mysql
      - FORGEJO__database__HOST=db:3306
      - FORGEJO__database__NAME=${DB_NAME}
      - FORGEJO__database__USER=${DB_USER}
      - FORGEJO__database__PASSWD=${DB_PASSWORD}
      # Server Configuration
      - FORGEJO__server__DOMAIN=${FORGEJO_DOMAIN}
      - FORGEJO__server__ROOT_URL=${FORGEJO_ROOT_URL}
      - FORGEJO__server__HTTP_PORT=${FORGEJO_HTTP_PORT}
      - FORGEJO__server__SSH_DOMAIN=${FORGEJO_SSH_DOMAIN}
      - FORGEJO__server__SSH_PORT=${FORGEJO_SSH_PORT}
      - FORGEJO__server__DISABLE_SSH=false
      # Storage Configuration
      - FORGEJO__server__APP_DATA_PATH=/data
      # Security Tokens (will be auto-generated on first run if not provided)
      - FORGEJO__security__INSTALL_LOCK=true
      - FORGEJO__security__INTERNAL_TOKEN=${FORGEJO_INTERNAL_TOKEN}
      - FORGEJO__security__PASSWORD_HASH_ALGO=pbkdf2
      # OAuth2
      - FORGEJO__oauth2__JWT_SECRET=${FORGEJO_JWT_SECRET}
      # LFS Configuration
      - FORGEJO__server__LFS_START_SERVER=true
      - FORGEJO__server__LFS_JWT_SECRET=${FORGEJO_LFS_JWT_SECRET}
      # Email Configuration (optional)
      - FORGEJO__mailer__ENABLED=false
      # Service Configuration
      - FORGEJO__service__DISABLE_REGISTRATION=true
      - FORGEJO__service__REGISTER_EMAIL_CONFIRM=false
      - FORGEJO__service__ENABLE_NOTIFY_MAIL=false
      # Repository Configuration
      - FORGEJO__repository__DEFAULT_BRANCH=main
      - FORGEJO__repository__DEFAULT_MERGE_STYLE=merge
      # Offline Mode
      - FORGEJO__server__OFFLINE_MODE=true
    restart: unless-stopped
    networks:
      - forgejo
    volumes:
      - ./forgejo:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "${FORGEJO_HTTP_PORT}:3000"
      - "${FORGEJO_SSH_PORT}:22"
    depends_on:
      - db
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s

  db:
    image: mysql:8.0
    container_name: forgejo-db
    restart: unless-stopped
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_DATABASE=${DB_NAME}
      - MYSQL_USER=${DB_USER}
      - MYSQL_PASSWORD=${DB_PASSWORD}
    networks:
      - forgejo
    volumes:
      - ./mysql:/var/lib/mysql
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

networks:
  forgejo:
    external: false

.env Configuration

Create a .env file:

DB_NAME=forgejo
DB_USER=forgejo
DB_PASSWORD=your_secure_db_password
MYSQL_ROOT_PASSWORD=your_secure_root_password
FORGEJO_DOMAIN=git.example.com
FORGEJO_ROOT_URL=https://git.example.com
FORGEJO_SSH_DOMAIN=git.example.com
FORGEJO_HTTP_PORT=3003
FORGEJO_SSH_PORT=2002
FORGEJO_INTERNAL_TOKEN=your_internal_token
FORGEJO_JWT_SECRET=your_secure_jwt_secret
FORGEJO_LFS_JWT_SECRET=your_secure_lfs_jwt_secret

Migration Steps

Step 1: Start MySQL and Import Database

# Start only MySQL first
docker-compose up -d db

# Wait for readiness
docker-compose logs db | grep "ready for connections"

# Create and populate database
docker-compose exec -T db bash -c \
  'mysql -u root -p$MYSQL_ROOT_PASSWORD -e "DROP DATABASE IF EXISTS forgejo; CREATE DATABASE forgejo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"'

docker-compose exec -T db bash -c \
  'mysql -u root -p$MYSQL_ROOT_PASSWORD forgejo' < _forgejo_backup/forgejo-backup.sql

# Verify import
docker-compose exec -T db bash -c \
  'mysql -u root -p$MYSQL_ROOT_PASSWORD forgejo -e "SELECT COUNT(*) as users FROM user; SELECT COUNT(*) as repos FROM repository;"'

Step 2: Prepare Repository Data

Map your backup structure to Docker’s expected paths.

Path Mapping Reference, bare metal structure → Docker structure:

Bare Metal Path Docker Path Purpose
gitea-repositories/ /data/git/repositories/ Git repositories
avatars/ /data/gitea/avatars/ User avatars
jwt/ /data/gitea/jwt/ JWT secrets
lfs/ /data/gitea/lfs/ Large file storage
sessions/ /data/gitea/sessions/ Session data
indexers/ /data/gitea/indexers/ Search indices


# Copy backup data
mkdir -p forgejo/data
cp -r _forgejo_backup/var/lib/gitea/data/* forgejo/data/

# Fix permissions for git user (UID 1000)
sudo chown -R 1000:1000 forgejo/data/

# Create correct directory structure and move files
docker-compose run --rm server bash -c '
  mkdir -p /data/git/repositories /data/gitea/avatars
  if [ -d /data/gitea-repositories ]; then
    cp -r /data/gitea-repositories/* /data/git/repositories/
  fi
  if [ -d /data/avatars ]; then
    cp -r /data/avatars/* /data/gitea/avatars/
  fi
'

Step 3: Start Forgejo

# Start the container
docker-compose up -d
# Monitor startup (healthy within 30 seconds)
docker-compose logs -f
docker-compose ps

Step 4: Verify Everything Works

# Check API response
curl -s http://localhost:3000/api/v1/repos/youruser/yourrepo | python3 -m json.tool

# Verify database
docker-compose exec -T db bash -c \
  'mysql -u root -p$MYSQL_ROOT_PASSWORD forgejo -e "SELECT COUNT(*) FROM repository; SELECT COUNT(*) FROM user;"'

# Test SSH clone (uses host SSH on port 22)
git clone ssh://git@example.com/youruser/yourrepo.git

Results

My migration resulted in:

  • All repositories migrated and accessible
  • All user accounts preserved
  • SSH push/pull working
  • Web interface fully functional
  • Containerized, portable setup
  • MySQL backend with health checks

Afterwords

With the local setup working, deployment to production involved:

  1. Copying the docker-compose setup including the persistent data mounts to production server
  2. Configuring reverse proxy (Caddy/Nginx) for HTTPS
  3. Setting up SSL certificates

The Docker setup is now portable and version-controlled.


References: