Deploy multiple applications to a single Dokku server with centralized configuration. Import existing apps from a server, migrate between servers, or set up fresh deployments.
Features
- Multi-app orchestration - Deploy multiple independent repos from one config
- Hierarchical configuration - Parent settings cascade to deployments, child overrides parent
- Smart deployment - Skips unchanged apps (compares git commits)
- Secrets management - Hierarchical
.envfiles, gitignored - Pre/post deploy hooks - Run scripts before/after deployment
- Tag-based filtering - Deploy subsets by tag (
--tag staging,--tag api) - PostgreSQL auto-setup - Opt-in automatic database provisioning
- Let's Encrypt SSL - Opt-in automatic SSL certificate provisioning
- MySQL service exposure - Optional
mysql:exposeautomation for local-only DB access - Storage mounts, ports, domains - Full Dokku configuration support
- Server import/migration - Import all apps from existing server, migrate to new server
- Backup & Restore - Backup/restore PostgreSQL databases and storage mounts with xz compression
Prerequisites
Configure your Dokku server in ~/.ssh/config:
Host <ssh-alias>
HostName <your-server-ip>
User root
IdentityFile ~/.ssh/<your-key>
IdentitiesOnly yes
Then use <ssh-alias> as the ssh_alias in your config.json.
Global Install
You can run this from any project by symlinking it into your PATH:
mkdir -p ~/bin ln -sf /absolute/path/to/dokku-multideploy/deploy.sh ~/bin/deploy chmod +x /absolute/path/to/dokku-multideploy/deploy.sh
Then use it inside any app folder that has config.json:
deploy --dry-run deploy --sync
Notes:
- If you use fish shell and
deployis not found, add~/binto fish paths:set -Ux fish_user_paths ~/bin $fish_user_pathsThen restart your shell and verify with:command -v deploy - By default,
deploylooks forconfig.jsonnext to the invoked script/symlink, then falls back to$PWD/config.json. - You can always override explicitly:
CONFIG_FILE=$PWD/config.json deploy --dry-run
Quick Start
Migrate existing Dokku server (most common)
# 1. Clone this repo git clone https://github.com/benmarten/dokku-multideploy.git # 2. Import all apps from your existing server to a separate directory ./dokku-multideploy/deploy.sh --import ./apps --ssh <ssh-alias> # 3. Backup databases and storage mounts cd apps ln -s ../dokku-multideploy/deploy.sh . ./deploy.sh --backup # 4. Update config.json with new server details # Change ssh_host and ssh_alias to new server # 5. Deploy everything to new server ./deploy.sh --dry-run # Preview first ./deploy.sh # 6. Restore backups on new server SSH_HOST=<new-server> ./restore.sh backups/<timestamp>
Fresh setup
# 1. Clone and set up your project git clone https://github.com/benmarten/dokku-multideploy.git cd <your-project> ln -s <path-to>/dokku-multideploy/deploy.sh . cp <path-to>/dokku-multideploy/config.example.json config.json # Edit config.json with your apps # 2. Add secrets (optional) mkdir -p .env echo "DATABASE_PASSWORD=secret" > .env/api.example.com # 3. Deploy! ./deploy.sh
Import from Existing Server
Already have apps running on a Dokku server? Import everything:
# Import all apps from your Dokku server ./deploy.sh --import ./apps --ssh <ssh-alias> # Import without secrets (env vars) ./deploy.sh --import ./apps --ssh <ssh-alias> --no-secrets
This will:
- Clone all app git repos to
./apps/<domain>/ - Generate
config.jsonwith settings (domains, ports, storage, postgres, letsencrypt) - Split env vars on import: sensitive keys go to
.env/, non-sensitive keys go to deploymentenv_varsinconfig.json
Then symlink deploy.sh and you're ready:
cd ./apps ln -s <path-to>/dokku-multideploy/deploy.sh . ./deploy.sh --dry-run
Server Migration
Migrate all apps to a new server:
# 1. Import from current server (if not already done) ./deploy.sh --import ./apps --ssh <old-server> # 2. Set up new server with Dokku ssh <new-server> "wget -NP . https://dokku.com/install/v0.34.4/bootstrap.sh && sudo bash bootstrap.sh" # 3. Update SSH config for new server # Edit ~/.ssh/config to add <new-server> alias # 4. Update config.json # Change ssh_host and ssh_alias to new server # 5. Deploy everything to new server ./deploy.sh
The script will create all apps, configure domains, env vars, storage mounts, ports, postgres, and letsencrypt on the new server.
Directory Structure
your-project/
├── deploy.sh # Symlink to dokku-multideploy/deploy.sh
├── restore.sh # Symlink to dokku-multideploy/restore.sh
├── config.json # Your deployment configuration
├── .env/ # Secret environment variables (gitignored)
│ ├── _api # Shared secrets for all "api" source_dir apps
│ ├── api.example.com # Secrets specific to api.example.com
│ └── api-staging.example.com
├── certs/ # Custom SSL certificates (optional)
│ └── api-example-com/
│ ├── server.crt
│ └── server.key
├── backups/ # Backup files (gitignored)
│ └── 2026-01-06_143022/ # Timestamped backup folder
│ ├── api-example-com-db.dump.xz
│ └── api-example-com-storage-1.tar.xz
├── api/ # Your API source code
│ ├── Dockerfile
│ ├── pre-deploy.sh # Runs before deploy (e.g., migrations)
│ └── post-deploy.sh # Runs after deploy (e.g., seed data)
└── web/ # Your web app source code
└── Dockerfile
Configuration
config.json
{
"ssh_host": "dokku@<your-server-ip>",
"ssh_alias": "<ssh-alias>",
"global_domain": "example.com",
"letsencrypt_email": "admin@example.com",
"mysql_expose": {
"mysql-prod": "127.0.0.1:3306",
"mysql-staging": "127.0.0.1:3307",
"mysql-test": "127.0.0.1:3308",
"mysql-test-v2": "127.0.0.1:3309"
},
"api": {
"source_dir": "api",
"subtree_prefix": "services/api",
"branch": "main",
"builder": "dockerfile",
"postgres": true,
"letsencrypt": true,
"env_vars": {
"NODE_ENV": "production"
},
"deployments": {
"api.example.com": {
"tags": ["production", "api"],
"env_vars": {
"LOG_LEVEL": "warn"
}
},
"api-staging.example.com": {
"tags": ["staging", "api"],
"env_vars": {
"LOG_LEVEL": "debug"
}
}
}
}
}Configuration Options
Root Level
| Key | Description |
|---|---|
ssh_host |
Full SSH host for git push (e.g., dokku@1.2.3.4) |
ssh_alias |
SSH alias for commands (e.g., dokku if configured in ~/.ssh/config) |
global_domain |
Base domain used to synthesize app domains during import when only .dokku exists |
letsencrypt_email |
Global email used for Let's Encrypt certificate requests |
dokku_networks |
Array of Dokku attachable network names to ensure before deploy/config runs |
mysql_expose |
Map of MySQL service name to bind address for dokku mysql:expose (e.g., {"mysql-prod":"127.0.0.1:3306"}) |
Parent Level (e.g., "api", "web")
| Key | Description |
|---|---|
source_dir |
Directory containing the source code and Dockerfile. Supports relative paths (api, ../sibling-repo) or absolute paths (/path/to/project) |
subtree_prefix |
Optional monorepo path to deploy via git subtree split --prefix (relative to repo root) |
branch |
Git branch to deploy (auto-detects if not set) |
builder |
Dokku builder type (for example dockerfile, herokuish, or pack) |
postgres |
Auto-create and link PostgreSQL database (true/false) |
letsencrypt |
Auto-provision Let's Encrypt SSL (true/false) |
env_vars |
Environment variables (set at runtime) |
build_args |
Docker build arguments (set at build time) |
storage_mounts |
Array of storage mounts - string "host:container" or object {"mount": "host:container", "backup": false} |
ports |
Array of port mappings ("http:80:3000") |
extra_domains |
Additional domains to add |
plugins |
Dokku plugins to install |
dokku_settings |
Generic Dokku plugin settings map (for example {"nginx":{"client-max-body-size":"100m"}}) |
Deployment Level
Same options as parent level, plus:
| Key | Description |
|---|---|
tags |
Array of tags for filtering (["production", "api"]) |
Child settings override parent settings.
Dokku Settings Map
Use dokku_settings when you want config-driven dokku <plugin>:set behavior:
{
"dokku_settings": {
"nginx": {
"client-max-body-size": "100m"
}
}
}Notes:
- Parent and deployment
dokku_settingsare merged (deployment overrides parent per key). - Keys are applied as
dokku <plugin>:set <app> <key> <value>during deploy and--config-only. --syncnow comparesdokku_settingsas part of drift detection.- Import support currently captures
nginx.client-max-body-sizeonly; other plugin settings are still deploy-only unless you add custom import logic.
MySQL Service Exposure
Use root-level mysql_expose to declare local-only binds for Dokku MySQL services:
{
"mysql_expose": {
"mysql-prod": "127.0.0.1:3306",
"mysql-staging": "127.0.0.1:3307"
}
}Behavior:
- Applied after deployment/config updates in full (unfiltered) deploy/config runs.
- Skipped for filtered runs (
--tag, explicit app names, or--no-prod) unless--force-mysql-exposeis provided. - Skips the service if the desired bind is already active.
- If a different bind exists, unexposes all current bindings and re-exposes with the configured address.
- Accepts binds in the form
127.0.0.1:<port>or0.0.0.0:<port>where port is 1–65535. - If a listed service does not exist, deploy continues with a warning.
- In
--dry-runmode, prints the command that would run without executing it.
For local clients like DBeaver, prefer 127.0.0.1:<port> and connect through SSH tunnel:
ssh -N -L 13306:127.0.0.1:3306 -L 13307:127.0.0.1:3307 <ssh-alias>
Then connect to 127.0.0.1 using local ports (13306, 13307, ...). Local port numbers are arbitrary — choose values that do not conflict with services already bound on your workstation.
Dokku Network Creation
Use root-level dokku_networks to ensure attachable Docker networks exist:
{
"dokku_networks": ["vt-prod-internal", "vt-nonprod-internal"]
}Behavior:
- Applied before deployment/config updates.
- Idempotent: existing networks are skipped.
- In
--dry-run, commands are printed but not executed.
Secrets (.env files)
Secrets are loaded hierarchically:
.env/_<source_dir>- Shared secrets for all apps with that source_dir.env/<domain>- Domain-specific secrets (overrides shared)
# .env/_api (shared by all api deployments) DATABASE_PASSWORD=shared-secret API_KEY=common-key # .env/api.example.com (production-specific) DATABASE_PASSWORD=production-secret
Usage
# Deploy all apps ./deploy.sh # Deploy specific app(s) ./deploy.sh api.example.com ./deploy.sh api.example.com www.example.com # Deploy by tag ./deploy.sh --tag staging ./deploy.sh --tag api ./deploy.sh --tag staging --tag api # OR logic # Skip production ./deploy.sh --no-prod # Apply mysql_expose even on filtered deploys ./deploy.sh --tag staging --force-mysql-expose # Dry run (see what would happen) ./deploy.sh --dry-run # Force deploy (even if no code changes) ./deploy.sh --force # Update config only (no code deploy, just env vars + restart) ./deploy.sh --config-only api.example.com # Skip confirmation prompts ./deploy.sh --yes
Key options:
--tag <tag>: deploy only apps with matching tag(s)--no-prod: exclude apps taggedproduction--force-mysql-expose: apply rootmysql_exposeeven when using filtered deploy selection
Sync Check
Compare local config.json against live Dokku state without deploying:
# Check all selected deployments ./deploy.sh --sync # Check only a subset ./deploy.sh --sync --tag staging ./deploy.sh --sync api.example.com # Re-import live state before checking ./deploy.sh --sync --refresh-sync # Clear cache and re-import ./deploy.sh --sync --reset-sync # Patch local config.json from live Dokku values ./deploy.sh --sync --sync-apply # Preview patch changes without writing ./deploy.sh --sync --sync-apply --dry-run
--sync behavior:
- Imports current Dokku app config to
.sync-cache/(no git clone, no env secret export) - Compares local vs remote by domain
- Reports:
✓ In sync✗ Missing on Dokku⚠ Driftwith per-field differences
branchis ignored in drift detection (localbranchis source selection; Dokku deploy target is standardized tomaster)
Cache options:
--refresh-sync: refresh.sync-cache/config.jsonbefore comparing--reset-sync: clear the sync cache directory, then import fresh--sync-dir <dir>: use a custom cache directory--sync-apply: write drifted fields from Dokku into deployment-level entries in localconfig.json
Exit codes:
0: all selected deployments are in sync1: drift/missing detected or sync check failed
Backup
Backup PostgreSQL databases and storage mounts to compressed .xz files:
# Backup all apps ./deploy.sh --backup # Backup to custom directory ./deploy.sh --backup --backup-dir ~/dokku-backups # Backup specific app ./deploy.sh --backup api.example.com # Backup by tag ./deploy.sh --backup --tag production # Dry run (see what would be backed up) ./deploy.sh --backup --dry-run
This creates timestamped backup folders:
./backups/2026-01-06_143022/
├── api-example-com-db.dump.xz # PostgreSQL dump (pg_dump custom format)
├── api-example-com-storage-1.tar.xz # Storage mount #1 contents
└── api-example-com-storage-2.tar.xz # Storage mount #2 contents
Backups are saved to ./backups/<timestamp>/ by default (gitignored).
Storage backup notes:
storage_mountssupports object entries like{"mount":"<host>:<container>","backup":false}.- Mounts marked
backup:falseare skipped in backup mode and explicitly listed in CLI output. - Mounts larger than
100MBare skipped by default and explicitly listed in CLI output. - Override threshold with
BACKUP_MAX_STORAGE_MB(0disables size-based skipping). - Backup mode prints a final consolidated "Manual rsync required" list across all apps.
- Use direct host-to-host
rsyncfor skipped large volumes during migrations.
Restore
Restore PostgreSQL databases and storage mounts from a backup:
# Restore to default server (from config.json ssh_alias) ./restore.sh backups/2026-01-31_085506 # Restore to specific server SSH_HOST=co2 ./restore.sh backups/2026-01-31_085506 # Dry run (see what would be restored) ./restore.sh backups/2026-01-31_085506 --dry-run
The restore script will:
- Install postgres plugin if needed
- Create databases if they don't exist, or import into existing ones
- Extract storage archives to
/var/lib/dokku/data/storage/<app>/ - Fix permissions for Dokku
Workflow for server migration:
# 1. Deploy apps to new server (creates apps, empty DBs, storage mounts) CONFIG_FILE=config-newserver.json ./deploy.sh # 2. Restore data from backup SSH_HOST=newserver ./restore.sh backups/2026-01-31_085506
Deploy Hooks
Create pre-deploy.sh or post-deploy.sh in your source directory:
# api/pre-deploy.sh #!/bin/bash echo "Running migrations..." npm run db:migrate # api/post-deploy.sh #!/bin/bash echo "Seeding database..." npm run db:seed
The APP_NAME environment variable is available in hooks.
How It Works
- Parse config - Reads
config.json, merges parent/child settings - Filter - Applies tag filters and deployment selection
- For each app:
- Sync with git origin
- Check if deployment needed (compare commits)
- Create Dokku app if needed
- Configure PostgreSQL if enabled
- Set domains
- Mount storage
- Set port mappings
- Load secrets from
.envfiles - Set env vars and build args
- Run pre-deploy hook
- Git push to Dokku
- Run post-deploy hook
- Enable Let's Encrypt if configured
Requirements
bash4.0+jq- JSON processor (brew install jqorapt install jq)gitsshaccess to your Dokku serverxz(for backup mode - usually pre-installed)
SSH Configuration
Add to ~/.ssh/config:
Host <ssh-alias>
HostName <your-server-ip>
User dokku
IdentityFile ~/.ssh/<your-key>
Then set "ssh_alias": "<ssh-alias>" in config.json.
License
MIT