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](https://www.deployhq.com) — from local development with DDEV, through server configuration, to automated zero-downtime deployments.

* * *

## What You'll Need

- A [Hetzner Cloud account](https://www.hetzner.com/cloud/)
- A [DeployHQ account](https://www.deployhq.com/signup) (free tier works for one project)
- A GitHub, GitLab, or Bitbucket account
- [DDEV](https://ddev.com/) 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](https://ddev.com/) provides a Docker-based local development environment that mirrors your production server. If you don't have DDEV installed, follow the [DDEV installation guide](https://ddev.readthedocs.io/en/stable/users/install/ddev-installation/).

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](https://craftcms.com/docs/5.x/system/project-config.html), 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](https://console.hetzner.cloud/) 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](https://www.deployhq.com/signup) 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](https://deployhq.com/features/zero-downtime-deployments) enabled, [DeployHQ](https://www.deployhq.com) 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](https://deployhq.com/blog/build-pipelines-in-deployhq-streamline-your-deployment-workflow) 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:

1. Symlinks the persistent `.env` file into the new release
2. Runs any pending database migrations
3. Applies Project Config changes (fields, sections, entry types) from the YAML files in Git
4. Clears all Craft caches
5. 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](https://deployhq.com/support/projects/configuration-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](https://deployhq.com/blog/how-to-set-up-git-pull-deployments-with-deployhq) 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](https://www.deployhq.com) 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:

1. **Make changes locally** in DDEV — edit templates, add fields, create sections
2. **Craft saves structural changes** to `config/project/*.yaml` automatically
3. **Commit and push** to Git:`bash
git add .
git commit -m "Add blog section with categories"
git push origin main
`
4. **DeployHQ deploys automatically** — installs dependencies, transfers files, runs migrations, applies Project Config
5. **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](https://www.deployhq.com) will create a new release. You can also roll back to any previous deployment from the [DeployHQ](https://www.deployhq.com) 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](https://www.deployhq.com) deploys the new version and runs migrations on the server automatically.

### How do I deploy to staging and production?

Create two servers in [DeployHQ](https://www.deployhq.com). 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](https://www.deployhq.com) for automated deployments gives you a professional, Git-based workflow for under €5/month. Structural changes flow through Project Config in Git, [DeployHQ](https://www.deployhq.com) handles the deployment pipeline, and zero-downtime releases keep your site live throughout every deploy.

[Start your free](https://www.deployhq.com/signup)[DeployHQ](https://www.deployhq.com) trial — no credit card required.

