Migrate from Virtualmin
This guide walks you through migrating PHP+MySQL sites from a Virtualmin server to Kuploy. The process uses the kuploy-migrate toolkit to automate file copying, database dumps, and Dockerfile generation.
Overview
The migration flow:
- Export — run the migrate script on your Virtualmin server to push site files and database dumps to a GitHub repo
- Deploy — create projects, databases, and applications in Kuploy pointing to that repo
- Switch DNS — update A records to point to your Kuploy cluster
Each Virtualmin site becomes a Kuploy project with its own MariaDB database and application deployment.
Prerequisites
- A Virtualmin server with root access
git,curl,rsync,mysql,mysqldumpinstalled on the server- A GitHub Personal Access Token with
reposcope - A Kuploy instance with a connected GitHub provider
- A container registry configured in Kuploy (Settings → Registry)
Phase 0: Prepare the Base Image
Kuploy-migrate uses a shared base Docker image for all PHP sites. Build and push it once:
git clone https://github.com/ceduth/kuploy-migrate
cd kuploy-migrate
docker build --platform linux/amd64 -t yourregistry/php-legacy:8.1 -f base-Dockerfile .
docker push yourregistry/php-legacy:8.1
The base image includes PHP 8.1, Apache with mod_rewrite and AllowOverride All, mysqli/PDO extensions, Composer, and sensible php.ini defaults (64M uploads, 256M memory, 300s execution time).
If your cluster runs on ARM nodes, build multi-platform:
docker buildx build --platform linux/amd64,linux/arm64 --push -t yourregistry/php-legacy:8.1 -f base-Dockerfile .
Phase 1: Export Sites from Virtualmin
Run the Migration Script
On your Virtualmin server:
export GITHUB_PAT=ghp_your_token
cd /path/to/kuploy-migrate
chmod +x virtualmin/migrate.sh
# Dry run first
./virtualmin/migrate.sh \
--pat $GITHUB_PAT \
--repo yourorg/virtualmin-sites \
--sites "mysite" \
--dry-run
# Run for real
./virtualmin/migrate.sh \
--pat $GITHUB_PAT \
--repo yourorg/virtualmin-sites \
--sites "mysite"
What the Script Does
For each site, the script:
- Parses
/etc/webmin/virtual-server/domains/*for database credentials, domain names, and PHP mode - Copies
public_html/with exclusions (logs, archives, mail, phpMyAdmin) - Detects PHP apps outside
public_html/(by checking forartisanorvendor/autoload.php) - Copies the app root preserving the original directory name
- Dumps the MySQL database using per-site credentials
- Generates a minimal Dockerfile
- Saves credentials to a local file (never committed to Git)
- Pushes everything to the GitHub repo
Repository Structure
The script creates this structure in your GitHub repo:
virtualmin-sites/
├── mysite/
│ ├── Dockerfile
│ ├── public_html/
│ ├── mysite/ ← PHP app root (if detected)
│ ├── db/mysite.sql ← MySQL dump
│ └── site.json ← Metadata
└── .gitignore
PHP App Detection
The script checks for code directories outside public_html/ at ~/<site>/<site>/, ~/<site>/app/, or ~/<site>/laravel/. It looks for either artisan (Laravel) or vendor/autoload.php (any Composer app).
The original directory name is preserved so that require() paths in index.php continue to work. For example, if your index.php does require('../mysite/vendor/autoload.php'), the Dockerfile copies to /var/www/mysite/.
Custom app directory: If your PHP app root has a non-standard name (e.g., sita instead of the site name), use the --app-dir flag:
./virtualmin/migrate.sh \
--pat $GITHUB_PAT \
--repo yourorg/virtualmin-sites \
--sites "mysite" \
--app-dir "mysite:sita"
This tells the script to look for the app at ~/<site>/sita/ in addition to the default locations. The directory name is preserved in the Dockerfile for path compatibility.
Dependency handling:
- If the app has a
composer.json, vendor is excluded from Git and the Dockerfile runscomposer install --no-devat build time - If there is no
composer.json, vendor is committed as-is
Phase 2: Deploy to Kuploy
You can deploy sites either via the Site Import API (recommended for batch migrations) or manually through the dashboard.
Option A: Site Import API (Recommended)
The Site Import API creates the project, database, application, environment variables, and domain in a single API call per site.
Setup:
- Generate an API key from Settings → API Keys
- Note your Organization ID and GitHub Git Provider ID from the dashboard
Import all sites:
export API_KEY="your-api-key"
export KUPLOY_URL="https://console.example.com"
export ORG_ID="org_xxx"
export GITHUB_ID="gh_xxx"
export REPO="yourorg/virtualmin-sites"
for site_dir in ${REPO_DIR}/*/; do
site=$(basename "$site_dir")
domain=$(jq -r '.domain' "$site_dir/site.json")
db_name=$(jq -r '.db_mysql' "$site_dir/site.json")
db_user=$(jq -r '.mysql_user' "$site_dir/site.json")
db_pass=$(grep "^$site " "$CREDS_FILE" | awk '{print $4}')
curl -s -X POST "$KUPLOY_URL/api/site-import" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"organizationId\": \"$ORG_ID\",
\"name\": \"$site\",
\"domain\": \"$domain\",
\"database\": {
\"type\": \"mariadb\",
\"name\": \"$db_name\",
\"user\": \"$db_user\",
\"password\": \"$db_pass\"
},
\"source\": {
\"type\": \"github\",
\"repo\": \"$REPO\",
\"branch\": \"main\",
\"buildPath\": \"/$site\",
\"buildType\": \"dockerfile\",
\"githubId\": \"$GITHUB_ID\"
},
\"port\": 80
}" | jq .
done
The API automatically sets DB_HOST, DB_DATABASE, DB_USERNAME, and DB_PASSWORD on the application. For Laravel sites, add extra env vars via the envVars field:
"envVars": {
"APP_KEY": "base64:xxx",
"APP_ENV": "production",
"APP_DEBUG": "false"
}
Get the APP_KEY from the original server:
grep APP_KEY /home/mysite/mysite/.env
Import the Database Dump
After the Site Import API creates the database container and it's running, import the SQL dump:
# Find the pod name
kubectl get pods -n project-mysite
# Import the dump (use -i, not -it, when piping stdin)
kubectl exec -i <mariadb-pod> -n project-mysite -- \
mariadb -u root -p'<root_password>' mysite < mysite/db/mysite.sql
# Verify tables were created
kubectl exec -i <mariadb-pod> -n project-mysite -- \
mariadb -u root -p'<root_password>' mysite -e "SHOW TABLES;"
MariaDB containers use the mariadb command, not mysql. Get the root password from the pod environment:
kubectl exec -i <mariadb-pod> -n project-mysite -- env | grep MARIADB_ROOT
Option B: Manual Setup (Dashboard)
If you prefer to set up each site manually:
- Create a Project — click New Project, name it after the site
- Create a MariaDB Database — Add Resource → Database → MariaDB, set name/user/password
- Create an Application — Add Resource → Application, set source to GitHub, repo
yourorg/virtualmin-sites, branchmain, build path/<site>, build type Dockerfile, port 80 - Set Environment Variables —
DB_HOST,DB_DATABASE,DB_USERNAME,DB_PASSWORD(andAPP_KEYetc. for Laravel) - Add Domain — go to Domains tab, add your domain
Add a Custom Domain
If you used the API, the domain is already configured. If manual:
- Go to the Domains tab
- Add your domain (e.g.,
mysite.com) - Kuploy automatically provisions a TLS certificate via cert-manager
Test Before Switching DNS
Verify the deployment works before updating DNS:
# Add to /etc/hosts on your local machine
<k8s-ingress-ip> mysite.com
Get the ingress IP:
kubectl get svc -n ingress-nginx
The EXTERNAL-IP on the ingress-nginx-controller service is your load balancer address.
DNS Migration Strategy
Do not include a domain in the Site Import API call if DNS still points to your old server. Domain creation triggers SSL certificate provisioning (Let's Encrypt HTTP-01 challenge), which will fail if the domain doesn't resolve to your Kuploy ingress IP.
Instead:
- Import the site without a domain — deploy and verify it works first
- Update DNS to point to the Kuploy ingress IP
- Add the domain via the Kuploy dashboard once DNS propagates
Get your Kuploy ingress IP:
kubectl get svc -n ingress-nginx
# Look for the EXTERNAL-IP on ingress-nginx-controller
Test Before Switching DNS
Verify the deployment works before updating DNS:
# Add to /etc/hosts on your local machine
<k8s-ingress-ip> mysite.com
Update DNS
Once verified, update the A record for your domain to point to the Kuploy ingress IP.
If DNS is managed by an external registrar (Enom, Namecheap, etc.), update the A record in their dashboard.
If DNS is managed by Virtualmin, the web UI validates that A records match the server's own IP and will reject changes. Two workarounds:
Option A — Uncheck validation (Web UI):
- Go to Server Configuration → DNS Records for the domain
- Change the A record for the bare domain (e.g.,
sitagroupgn.com.) to your Kuploy ingress IP - Uncheck "Validate new records?" at the bottom
- Click Save
Option B — CLI (bypasses validation entirely):
KUPLOY_IP="<kuploy-ingress-ip>"
virtualmin modify-dns --domain mysite.com --remove-record "mysite.com. A"
virtualmin modify-dns --domain mysite.com --add-record "mysite.com. A $KUPLOY_IP"
virtualmin modify-dns --domain mysite.com --remove-record "www.mysite.com. A"
virtualmin modify-dns --domain mysite.com --add-record "www.mysite.com. A $KUPLOY_IP"
Only update the A records for the bare domain and www. Leave all other records unchanged — especially MX, mail, webmail, SPF, and DMARC records if the Virtualmin server still handles email.
Leave MX, mail, webmail, SPF, and autodiscover records unchanged — they should keep pointing to the Virtualmin server if it still handles email.
Add the Domain in Kuploy
After DNS propagates (check with dig +short mysite.com A):
- Go to Projects → mysite → Domains
- Add
mysite.comwith HTTPS enabled - Kuploy automatically provisions a TLS certificate via Let's Encrypt
Alternatively, if you used the Site Import API and the import is partial or failed:
- Go to Import Sites
- Click the pencil icon on the import card to add the domain
- Click Retry — the import picks up where it left off
Phase 3: Batch Remaining Sites
Run the migrate script for multiple sites at once:
./virtualmin/migrate.sh \
--pat $GITHUB_PAT \
--repo yourorg/virtualmin-sites \
--sites "site1 site2 site3 site4 site5"
Then repeat the Kuploy setup (project, database, application, env vars, domain) for each site.
Troubleshooting
Import shows partial with "domain_creation: HTTP request failed"
The domain step failed, likely because DNS doesn't point to the Kuploy cluster yet. The site's project, database, and application were created, but the domain and deployment were not.
Fix:
- Go to Import Sites
- Click the pencil icon on the import card
- Clear the Domain field
- Click Save, then Retry
This retries without domain creation. Add the domain later via the dashboard after migrating DNS.
Import shows success but the site isn't reachable
success means all resources were created and the build was triggered, but the build and deployment are asynchronous. Check:
- Go to Projects → mysite → Application → Deployments to see build status
- If the build failed, check the deployment logs for errors
- If the build succeeded but no pod is running, click Deploy to trigger a redeploy
Retry button is spinning with no progress
The import is waiting for a long-running step (build, K8s resource creation). This can happen if your cluster's API is slow.
- Wait — builds can take up to 30 minutes for large sites
- If it stays stuck, refresh the page to reset the UI, then check the import status
Build fails with "failed to read dockerfile: is a directory"
The buildPath in the import points to a subdirectory (e.g., /mysite) but the builder couldn't find the Dockerfile. Ensure:
- The Dockerfile exists at
<buildPath>/Dockerfilein your repository - The build path starts with
/(e.g.,/mysite, notmysite)
Database dump import fails
If kubectl exec hangs or returns errors:
# Verify the pod is running
kubectl get pods -n project-mysite
# Check the MariaDB root password
kubectl exec -i <mariadb-pod> -n project-mysite -- env | grep MARIADB_ROOT
# Ensure you use -i (not -it) when piping stdin
kubectl exec -i <mariadb-pod> -n project-mysite -- \
mariadb -u root -p'<root_password>' mysite < mysite/db/mysite.sql
Caveats
Email
Virtualmin manages email (Postfix/Dovecot) per domain. Kuploy does not handle email. Options:
- Keep Virtualmin running solely for email (strip web hosting, keep MX records)
- Migrate to an external email provider (Google Workspace, Zoho, Mailgun)
phpMyAdmin
The migration script strips bundled phpMyAdmin from each site. Deploy one shared phpMyAdmin instance as a separate Kuploy application if needed.
PHP Mode
Virtualmin uses php_mode=fcgid (FastCGI). The base image uses mod_php (Apache module). This works for the vast majority of sites. If a site relies on per-user PHP pools, it may need adjustment.
Cron Jobs
Virtualmin cron jobs need to be migrated manually. Check existing crontabs:
crontab -u mysite -l
Set these up as Kuploy schedules or Kubernetes CronJobs.
Large Sites
GitHub has a soft limit of ~1GB per repo and 100MB per file. If a site has very large media files, consider serving those from S3 or object storage instead of including them in the Git repo.
Script Reference
| Flag | Required | Description |
|---|---|---|
--pat | Yes | GitHub Personal Access Token |
--repo | Yes | Target repo in owner/name format (created if missing) |
--sites | Yes | Space-separated list of site directories under /home/ |
--site-path | No | Map site to custom home path (format: site:/path, repeatable) |
--app-dir | No | Map site to custom PHP app directory name (format: site:dirname, repeatable) |
--base-img | No | Base Docker image (default: ceduth/php-legacy:8.1) |
--dry-run | No | Preview without executing |
The script saves database credentials to /tmp/virtualmin-migrate/credentials.txt (local only, never committed). Use these when setting up databases in Kuploy.