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:
- Copying the docker-compose setup including the persistent data mounts to production server
- Configuring reverse proxy (Caddy/Nginx) for HTTPS
- Setting up SSL certificates
The Docker setup is now portable and version-controlled.
References: