Craft CMS is a flexible, developer-focused content management system built on PHP and Yii2. Unlike WordPress, Craft stores content as structured entries with custom fields, making it a strong fit for content-heavy sites that need editorial control without the plugin bloat.
This guide walks through deploying a Craft CMS site to a Hetzner cloud server using DeployHQ — from local development with DDEV, through server configuration, to automated zero-downtime deployments.
What You'll Need
- A Hetzner Cloud account
- A DeployHQ account (free tier works for one project)
- A GitHub, GitLab, or Bitbucket account
- DDEV installed locally for development
- An SSH key pair on your local machine
Cost
| Component | Monthly Cost |
|---|---|
| Hetzner CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) | €4.35 |
| IPv4 address | €0.50 |
| DeployHQ Free plan | €0 |
| Total | €4.85/month |
Step 1: Set Up Craft CMS Locally with DDEV
DDEV provides a Docker-based local development environment that mirrors your production server. If you don't have DDEV installed, follow the DDEV installation guide.
Create your project repository on GitHub (or GitLab/Bitbucket), clone it, then set up Craft:
git clone git@github.com:your-username/your-craft-project.git
cd your-craft-project
# Configure DDEV for Craft CMS
ddev config --project-type=craftcms --docroot=web --create-docroot
# Start the environment
ddev start
# Install Craft CMS via Composer
ddev composer create -y --no-scripts craftcms/craft
# Run the Craft installer (sets up admin account and database)
ddev craft install
# Verify it's working
ddev launch
You should see the Craft CMS welcome page. The admin panel is at /admin.
Export the Database for Initial Import
For the first deployment, you need to seed the production database with your local data:
ddev export-db -f craft-initial.sql.gz
Keep this file — you'll import it to your production server in a later step.
Configure Craft for Git-Based Deployments
Craft CMS supports Project Config, which stores your content model (fields, sections, entry types) as YAML files in config/project/. These files are tracked in Git and applied on the server during deployment — so structural changes flow through your deployment pipeline, not manual admin panel clicks.
Make sure your .env file (local) is in .gitignore:
echo ".env" >> .gitignore
Commit and push everything:
git add .
git commit -m "Initial Craft CMS setup"
git push origin main
Step 2: Provision the Hetzner Server
Log in to the Hetzner Cloud Console and create a server:
- Location: Choose the region closest to your audience
- Image: Ubuntu 24.04
- Type: Shared vCPU, x86, CX22 (2 vCPU, 4 GB RAM, 40 GB SSD)
- SSH Keys: Add your public key
- Name:
craft-production
Create a Deploy User
ssh root@YOUR_SERVER_IP
# Create a dedicated deploy user
adduser --disabled-password --gecos "" deploy
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
# Allow deploy user to restart PHP-FPM and Nginx
echo "deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php8.3-fpm, /usr/bin/systemctl reload nginx" > /etc/sudoers.d/deploy
# Disable root SSH login
sed -i 's/^PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart sshd
Install the LEMP Stack
# Update packages
apt update && apt upgrade -y
# Nginx
apt install -y nginx
# PHP 8.3 and required extensions for Craft CMS
apt install -y php8.3-fpm php8.3-cli php8.3-mbstring php8.3-xml \
php8.3-curl php8.3-zip php8.3-gd php8.3-intl php8.3-bcmath \
php8.3-mysql php8.3-pgsql php8.3-imagick
# MariaDB (Craft supports MySQL/MariaDB and PostgreSQL)
apt install -y mariadb-server
# Composer
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Certbot for SSL
apt install -y certbot python3-certbot-nginx
# Firewall
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
Create the Database
mysql -u root << 'EOF'
CREATE DATABASE craft_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'craft_user'@'localhost' IDENTIFIED BY 'GENERATE_A_STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON craft_db.* TO 'craft_user'@'localhost';
FLUSH PRIVILEGES;
EOF
Import the Initial Database
Copy the database dump from your local machine to the server:
# From your local machine
scp craft-initial.sql.gz deploy@YOUR_SERVER_IP:/tmp/
Then import it on the server:
gunzip -c /tmp/craft-initial.sql.gz | mysql -u craft_user -p craft_db
Step 3: Configure Nginx for Craft CMS
sudo tee /etc/nginx/sites-available/craft > /dev/null << 'NGINX'
server {
listen 80;
server_name your-domain.com;
root /var/www/craft/current/web;
index index.php;
client_max_body_size 20M;
# Craft CMS pretty URLs
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP-FPM
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
# Block access to sensitive files
location ~ /\.(env|git|htaccess) {
deny all;
}
# Cache static assets
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
NGINX
sudo ln -sf /etc/nginx/sites-available/craft /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
Note the root path uses /var/www/craft/current/web — this is the symlink path that DeployHQ's zero-downtime deployment creates.
Add SSL
Once your domain points to the server:
sudo certbot --nginx -d your-domain.com
Step 4: Create the Application Directory and .env
sudo mkdir -p /var/www/craft
sudo chown deploy:deploy /var/www/craft
# Create the production .env file
cat > /var/www/craft/.env << 'EOF'
CRAFT_APP_ID=your-app-id
CRAFT_ENVIRONMENT=production
CRAFT_SECURITY_KEY=GENERATE_A_SECURITY_KEY
CRAFT_DB_DRIVER=mysql
CRAFT_DB_SERVER=127.0.0.1
CRAFT_DB_PORT=3306
CRAFT_DB_DATABASE=craft_db
CRAFT_DB_USER=craft_user
CRAFT_DB_PASSWORD=YOUR_DB_PASSWORD
CRAFT_DB_SCHEMA=public
CRAFT_DB_TABLE_PREFIX=
CRAFT_DEV_MODE=false
CRAFT_ALLOW_ADMIN_CHANGES=false
CRAFT_DISALLOW_ROBOTS=false
PRIMARY_SITE_URL=https://your-domain.com
EOF
chmod 600 /var/www/craft/.env
Important: CRAFT_ALLOW_ADMIN_CHANGES=false in production ensures structural changes can only come through Git (Project Config), not through the admin panel. This is a Craft CMS best practice for deployment workflows.
Generate a security key:
php -r "echo bin2hex(random_bytes(16)) . PHP_EOL;"
Step 5: Configure DeployHQ
Create a Project
Log in to DeployHQ and create a new project. Connect your Git repository.
Add Your Server
Go to Servers → New Server:
- Name: Production
- Protocol: SSH/SFTP
- Hostname: Your Hetzner server IP
- Username:
deploy - Authentication: SSH Key (add DeployHQ's public key to the deploy user's
authorized_keys) - Deployment Path:
/var/www/craft - Zero-downtime deployments: Enabled
With zero-downtime deployments enabled, DeployHQ deploys to a new release directory and atomically switches the current symlink when everything is ready. Your site stays live throughout the entire deployment.
Configure the Build Pipeline
Go to Build Pipeline → New Command. DeployHQ's build pipeline runs commands on DeployHQ's servers, so you don't need Composer installed on your production server.
Add a Composer build command:
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
Enable caching for vendor/** in Build Configuration → Cached Files to speed up subsequent deployments.
Add SSH Post-Deploy Commands
Go to SSH Commands and add a post-deploy command:
cd /var/www/craft/current
# Copy the .env from the parent directory (persists across releases)
ln -sf /var/www/craft/.env .env
# Apply database migrations and Project Config changes
php craft migrate/all --interactive=0
php craft project-config/apply --interactive=0
# Clear caches
php craft clear-caches/all
# Restart PHP-FPM to clear opcache
sudo /usr/bin/systemctl restart php8.3-fpm
This command:
- Symlinks the persistent
.envfile into the new release - Runs any pending database migrations
- Applies Project Config changes (fields, sections, entry types) from the YAML files in Git
- Clears all Craft caches
- Restarts PHP-FPM to pick up new code
Use Config Files for Per-Environment Settings
If you deploy to both staging and production, use DeployHQ's config files to inject different .env values per server — different database credentials, CRAFT_ENVIRONMENT=staging, CRAFT_ALLOW_ADMIN_CHANGES=true for staging, etc.
Enable Automatic Deployments
Enable automatic deployments so every push to main triggers a deployment:
git push → DeployHQ → composer install → Deploy files → Migrate + apply config → Clear caches → Done
Step 6: Deploy and Verify
Trigger your first deployment from the DeployHQ dashboard. Once complete:
# Verify the symlink
ls -la /var/www/craft/current
# Check the site
curl -I https://your-domain.com
# Check Craft status
cd /var/www/craft/current && php craft app/health-check
Visit your domain — your Craft CMS site should be live.
Development Workflow
Once everything is set up, your daily workflow looks like this:
- Make changes locally in DDEV — edit templates, add fields, create sections
- Craft saves structural changes to
config/project/*.yamlautomatically - Commit and push to Git:
bash git add . git commit -m "Add blog section with categories" git push origin main - DeployHQ deploys automatically — installs dependencies, transfers files, runs migrations, applies Project Config
- Production site is updated with zero downtime
Content (entries, assets) lives in the database and is managed through the Craft admin panel. Structural changes (fields, sections, settings) flow through Git and are applied automatically during deployment.
Common Questions
How do I handle uploads and assets?
Craft stores uploaded assets on disk by default. With zero-downtime deployments, each release gets a new directory — so you need to store assets outside the release directory. Configure an asset volume that points to a shared path:
# Create a shared assets directory
mkdir -p /var/www/craft/shared/assets
In Craft's admin panel (or config/volumes.php), set the asset volume's base path to /var/www/craft/shared/assets and the URL to /assets. Add a symlink in your SSH command:
ln -sf /var/www/craft/shared/assets /var/www/craft/current/web/assets
Can I use PostgreSQL instead of MariaDB?
Yes. Craft CMS supports PostgreSQL natively. Install postgresql instead of mariadb-server, create a PostgreSQL database, and update the CRAFT_DB_DRIVER and connection settings in your .env file.
What if a deployment fails?
With zero-downtime deployments, the current symlink still points to the previous release — your site stays live. Fix the issue, push again, and DeployHQ will create a new release. You can also roll back to any previous deployment from the DeployHQ dashboard.
How do I update Craft CMS itself?
Update Craft via Composer in your local DDEV environment:
ddev composer update craftcms/cms
ddev craft migrate/all
This updates the Composer lockfile and runs any new migrations locally. Commit the updated composer.lock and push — DeployHQ deploys the new version and runs migrations on the server automatically.
How do I deploy to staging and production?
Create two servers in DeployHQ. Map develop branch to your staging server and main to production. Use DeployHQ's config files to inject different .env values per environment, with CRAFT_ALLOW_ADMIN_CHANGES=true on staging so editors can make structural changes there, then export the Project Config YAML and merge it into main for production deployment.
The Bottom Line
Craft CMS with DDEV for local development, Hetzner for hosting, and DeployHQ for automated deployments gives you a professional, Git-based workflow for under €5/month. Structural changes flow through Project Config in Git, DeployHQ handles the deployment pipeline, and zero-downtime releases keep your site live throughout every deploy.
Start your free DeployHQ trial — no credit card required.