This guide walks you through everything you need to do after installing the Dehydrated tool to get your SSL certificate up and running on your website. We cover how to put the certificate on your web server, how to make it renew itself automatically, and how to keep everything healthy over time.
Think of Dehydrated as a helper robot that talks to a certificate authority (like Let's Encrypt or the Acme server) on your behalf, gets a trusted SSL certificate, and puts it in the right place so your visitors see the padlock icon in their browser.
| ? Before You Start: Make sure you have already completed the Installation Guide (INSTALLATION_GUIDE.md). This guide picks up right where that one leaves off. |
Once Dehydrated has fetched your certificate files, you need to copy them to your web server and tell the server where to find them. Below are instructions for the two most popular web servers: Apache and Nginx.
Apache is a widely-used web server. Follow these steps to install your certificate into it.
| 1 | Create the Apache SSL folder This is the folder where Apache will look for your certificate files. |
mkdir -p /etc/apache2/ssl
| 2 | Copy the certificate files across Replace 'demo.example.com' with your own domain name. |
cp /etc/dehydrated/certs/demo.example.com/fullchain.pem \
/etc/apache2/ssl/demo.example.com.fullchain.pem
cp /etc/dehydrated/certs/demo.example.com/privkey.pem \
/etc/apache2/ssl/demo.example.com.key
| 3 | Set the correct file permissions The certificate can be readable by anyone, but the private key must be locked down tightly. |
chmod 644 /etc/apache2/ssl/demo.example.com.fullchain.pem
chmod 600 /etc/apache2/ssl/demo.example.com.key
| 4 | Add the SSL block to your Apache configuration Open your Apache virtual host config file and add the settings below inside a <VirtualHost *:443> block. |
<VirtualHost *:443>
ServerName demo.example.com
DocumentRoot /var/www/demo.example.com
SSLEngine on
SSLCertificateFile /etc/apache2/ssl/demo.example.com.fullchain.pem
SSLCertificateKeyFile /etc/apache2/ssl/demo.example.com.key
# Only allow modern, secure TLS versions
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
SSLHonorCipherOrder off
# Tell browsers to always use HTTPS for the next 2 years
Header always set Strict-Transport-Security "max-age=63072000"
</VirtualHost>
| 5 | Test your Apache configuration, then reload Always test before reloading so you catch mistakes early. |
apache2ctl configtest
systemctl reload apache2
| ? Success sign: You should see 'Syntax OK' from the configtest command. After reloading, visit your website with https:// and check for the padlock. |
Nginx is another very popular web server. The process is nearly identical to Apache.
| 1 | Create the Nginx SSL folder |
mkdir -p /etc/nginx/ssl
| 2 | Copy the certificate files |
cp /etc/dehydrated/certs/demo.example.com/fullchain.pem \
/etc/nginx/ssl/demo.example.com.fullchain.pem
cp /etc/dehydrated/certs/demo.example.com/privkey.pem \
/etc/nginx/ssl/demo.example.com.key
| 3 | Set file permissions |
chmod 644 /etc/nginx/ssl/demo.example.com.fullchain.pem
chmod 600 /etc/nginx/ssl/demo.example.com.key
| 4 | Update your Nginx site configuration file Open your server block config and add the SSL settings. Also add a second block that redirects plain HTTP traffic to HTTPS automatically. |
server {
listen 443 ssl http2;
server_name demo.example.com;
root /var/www/demo.example.com;
ssl_certificate /etc/nginx/ssl/demo.example.com.fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/demo.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Force HTTPS for 2 years
add_header Strict-Transport-Security "max-age=63072000" always;
location / {
try_files $uri $uri/ =404;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name demo.example.com;
return 301 https://$host$request_uri;
}| 5 | Test and reload Nginx |
nginx -t
systemctl reload nginx
Instead of running the copy commands manually every time your certificate renews, you can make Dehydrated do it automatically by updating the 'deploy_cert' function inside the hook script. This is the recommended approach for production servers.
| 1 | Open the Cloudflare hook script in a text editor |
nano /etc/dehydrated/hooks/cloudflare.sh
| 2 | Find the deploy_cert section and update it Look for the line that says 'deploy_cert)' and update the block so it copies your new certificate files and reloads Nginx automatically whenever Dehydrated fetches a fresh certificate. |
deploy_cert)
local domain="$1"
local keyfile="$2"
local fullchainfile="$4"
local chainfile="$5"
local timestamp="$6"
log "=== deploy_cert ==="
log "Domain: ${domain}"
# Copy new certificate files to Nginx
cp "$fullchainfile" "/etc/nginx/ssl/${domain}.fullchain.pem"
cp "$keyfile" "/etc/nginx/ssl/${domain}.key"
chmod 644 "/etc/nginx/ssl/${domain}.fullchain.pem"
chmod 600 "/etc/nginx/ssl/${domain}.key"
# Reload Nginx so it picks up the new certificate
systemctl reload nginx
echo "Certificate deployed and Nginx reloaded"
;;
| ???? Tip: You can also add an optional notification here — for example, send a Slack message or email to let your team know a new certificate was deployed. |
SSL certificates expire (usually after 90 days). You never want your site to show a scary 'Your connection is not secure' warning. Dehydrated can renew your certificate automatically — you just need to schedule it to run regularly.
There are two common ways to schedule automatic renewal: using a Cron Job (the classic Linux scheduler) or using a Systemd Timer (a more modern alternative). Pick the one you are most comfortable with.
A cron job is a task you schedule to run at a specific time every day. Think of it like an alarm clock for your server.
| 1 | Open the crontab editor |
sudo crontab -e
| 2 | Add the renewal job (runs every day at 3:00 AM) |
# Dehydrated SSL certificate renewal
0 3 * * * source /etc/dehydrated/cloudflare.env && \
/usr/local/bin/dehydrated --cron >> /var/log/dehydrated/cron.log 2>&1
| 3 | (Optional) Add email alerts if the renewal fails If something goes wrong at 3 AM, this will send an email to your admin address. |
0 3 * * * source /etc/dehydrated/cloudflare.env && \
/usr/local/bin/dehydrated --cron >> /var/log/dehydrated/cron.log 2>&1 \
|| echo "Dehydrated renewal failed" | mail -s "SSL Renewal Error" admin@example.com
| 4 | (Optional) Multiple domains with separate settings If you have production and staging environments with different DNS providers, you can add separate jobs for each. |
# Production (Cloudflare DNS)
0 3 * * * source /etc/dehydrated/cloudflare.env && \
/usr/local/bin/dehydrated --cron --config /etc/dehydrated/config \
>> /var/log/dehydrated/cron.log 2>&1
# Staging (different DNS provider)
0 4 * * * source /etc/dehydrated/staging.env && \
/usr/local/bin/dehydrated --cron --config /etc/dehydrated/staging-config \
>> /var/log/dehydrated/staging-cron.log 2>&1
| ???? Tip: Dehydrated is smart — it only actually renews the certificate when there are 30 days or fewer left before it expires. So running it daily costs very little. |
Systemd timers are a more structured way to schedule tasks on modern Linux systems. They also restart automatically if the system reboots.
| 1 | Create the service file — This tells systemd what to run. |
sudo nano /etc/systemd/system/dehydrated.service
Paste the following content into the file:
[Unit]
Description=Dehydrated Certificate Renewal
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
EnvironmentFile=/etc/dehydrated/cloudflare.env
ExecStart=/usr/local/bin/dehydrated --cron
StandardOutput=append:/var/log/dehydrated/systemd.log
StandardError=append:/var/log/dehydrated/systemd.log
| 2 | Create the timer file — This tells systemd when to run the service. |
sudo nano /etc/systemd/system/dehydrated.timer
Paste the following content:
[Unit]
Description=Dehydrated Certificate Renewal Timer
Requires=dehydrated.service
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
| ? What does RandomizedDelaySec do? It adds a random delay of up to 1 hour so that all your servers do not hit the ACME server at exactly the same time. |
| 3 | Enable and start the timer |
sudo systemctl daemon-reload
sudo systemctl enable dehydrated.timer
sudo systemctl start dehydrated.timer
| 4 | Check the timer is active |
sudo systemctl status dehydrated.timer
sudo systemctl list-timers dehydrated.timer
| 1 | Run a forced renewal to check everything works end-to-end |
source /etc/dehydrated/cloudflare.env
dehydrated --cron --force --verbose
| 2 | (Optional) Test for a specific domain only |
dehydrated --cron --domain demo.example.com --force
| ? What to look for: No error messages in the output, and a line that says 'Certificate deployed and Nginx reloaded' (if you set up the hook in Section 1C). |
When Dehydrated needs to prove you own your domain, it creates a temporary DNS record called an ACME challenge. The hook script is a small program that creates and deletes this DNS record automatically. Dehydrated works with any DNS provider — you just need the right hook script.
You will need your GoDaddy API Key and API Secret. Get them from GoDaddy's Developer Portal.
#!/usr/bin/env bash
GD_API_KEY="YOUR_GODADDY_API_KEY"
GD_API_SECRET="YOUR_GODADDY_API_SECRET"
GD_API_URL="https://api.godaddy.com/v1"
create_txt_record() {
local domain="$1"
local token="$2"
curl -s -X PATCH "${GD_API_URL}/domains/${domain}/records" \
-H "Authorization: sso-key ${GD_API_KEY}:${GD_API_SECRET}" \
-H "Content-Type: application/json" \
--data '[{"data":"'"${token}"'","name":"_acme-challenge","ttl":600,"type":"TXT"}]'
}
delete_txt_record() {
local domain="$1"
curl -s -X DELETE "${GD_API_URL}/domains/${domain}/records/TXT/_acme-challenge" \
-H "Authorization: sso-key ${GD_API_KEY}:${GD_API_SECRET}"
}
case "$1" in
deploy_challenge) create_txt_record "$2" "$4"; sleep 120 ;;
clean_challenge) delete_txt_record "$2" ;;
esac
You will need the AWS CLI installed and configured with permissions to manage Route 53 records.
#!/usr/bin/env bash
AWS_REGION="us-east-1"
create_txt_record() {
local domain="$1"
local token="$2"
local zone_id
zone_id=$(aws route53 list-hosted-zones \
--query "HostedZones[?Name=='${domain}.'].Id" --output text)
aws route53 change-resource-record-sets \
--hosted-zone-id "$zone_id" \
--change-batch '{"Changes":[{"Action":"UPSERT","ResourceRecordSet":
{"Name":"_acme-challenge.'"${domain}"'","Type":"TXT","TTL":60,
"ResourceRecords":[{"Value":"\"'"${token}"'\""}]}}]}'
sleep 60
}
delete_txt_record() {
local domain="$1"
local zone_id
zone_id=$(aws route53 list-hosted-zones \
--query "HostedZones[?Name=='${domain}.'].Id" --output text)
# (fetch record value then delete it — see full script for details)
}
case "$1" in
deploy_challenge) create_txt_record "$2" "$4" ;;
clean_challenge) delete_txt_record "$2" ;;
esac
You will need a DigitalOcean Personal Access Token with write access.
#!/usr/bin/env bash
DO_API_TOKEN="YOUR_DIGITALOCEAN_TOKEN"
DO_API_URL="https://api.digitalocean.com/v2"
create_txt_record() {
local domain="$1"
local token="$2"
curl -s -X POST "${DO_API_URL}/domains/${domain}/records" \
-H "Authorization: Bearer ${DO_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"type":"TXT","name":"_acme-challenge","data":"'"${token}"'","ttl":1800}'
sleep 120
}
delete_txt_record() {
local domain="$1"
local record_id
record_id=$(curl -s "${DO_API_URL}/domains/${domain}/records" \
-H "Authorization: Bearer ${DO_API_TOKEN}" | \
jq -r '.domain_records[] | select(.type=="TXT" and .name=="_acme-challenge") | .id')
[[ -n "$record_id" ]] && curl -s -X DELETE \
"${DO_API_URL}/domains/${domain}/records/${record_id}" \
-H "Authorization: Bearer ${DO_API_TOKEN}"
}
case "$1" in
deploy_challenge) create_txt_record "$2" "$4" ;;
clean_challenge) delete_txt_record "$2" ;;
esac
If your website runs on several servers behind a load balancer, you need to copy the fresh certificate to all of them whenever it renews. This script does that via SSH.
#!/bin/bash
DOMAIN="demo.example.com"
CERT_DIR="/etc/dehydrated/certs/${DOMAIN}"
SERVERS=("server1.local" "server2.local" "server3.local")
for server in "${SERVERS[@]}"; do
echo "Deploying to $server..."
scp "${CERT_DIR}/fullchain.pem" root@$server:/etc/ssl/certs/${DOMAIN}.pem
scp "${CERT_DIR}/privkey.pem" root@$server:/etc/ssl/private/${DOMAIN}.key
ssh root@$server "systemctl reload nginx"
echo "Done: $server"
done
| ? SSH keys required: This script uses passwordless SSH. Make sure your server's root user has the public key of the machine running Dehydrated. |
If your web server runs inside a Docker container, copy the certificate files to a shared Docker volume, then restart the relevant containers.
#!/bin/bash
DOMAIN="demo.example.com"
CERT_DIR="/etc/dehydrated/certs/${DOMAIN}"
DOCKER_VOL="/var/docker/ssl"
mkdir -p "$DOCKER_VOL"
cp "${CERT_DIR}/fullchain.pem" "${DOCKER_VOL}/${DOMAIN}.pem"
cp "${CERT_DIR}/privkey.pem" "${DOCKER_VOL}/${DOMAIN}.key"
chmod 644 "${DOCKER_VOL}/${DOMAIN}.pem"
chmod 600 "${DOCKER_VOL}/${DOMAIN}.key"
docker restart nginx-proxy
docker restart web-app
echo "Docker containers updated with new certificates"
HAProxy requires a single combined file (certificate + private key joined together).
#!/bin/bash
DOMAIN="demo.example.com"
CERT_DIR="/etc/dehydrated/certs/${DOMAIN}"
HAPROXY_CERT_DIR="/etc/haproxy/certs"
# Combine certificate and private key into one file
cat "${CERT_DIR}/fullchain.pem" "${CERT_DIR}/privkey.pem" \
> "${HAPROXY_CERT_DIR}/${DOMAIN}.pem"
chmod 600 "${HAPROXY_CERT_DIR}/${DOMAIN}.pem"
systemctl reload haproxy
echo "HAProxy certificate deployed"
In Kubernetes, SSL certificates are stored as TLS secrets. This script creates or updates that secret whenever the certificate renews.
#!/bin/bash
DOMAIN="demo.example.com"
CERT_DIR="/etc/dehydrated/certs/${DOMAIN}"
NAMESPACE="production"
SECRET_NAME="tls-secret"
kubectl create secret tls $SECRET_NAME \
--cert="${CERT_DIR}/fullchain.pem" \
--key="${CERT_DIR}/privkey.pem" \
--namespace=$NAMESPACE \
--dry-run=client -o yaml | kubectl apply -f -
# Restart the ingress controller to pick up the new cert
kubectl rollout restart deployment/nginx-ingress-controller -n ingress-nginx
echo "Kubernetes secret updated"
This script converts your certificate to PFX format and uploads it to Azure Key Vault where other Azure services can use it.
#!/bin/bash
DOMAIN="demo.example.com"
CERT_DIR="/etc/dehydrated/certs/${DOMAIN}"
VAULT_NAME="mykeyvault"
CERT_NAME="dikshatest-site"
PFX_PASSWORD="SecurePassword123!"
# Convert to PFX format
openssl pkcs12 -export \
-out "/tmp/${CERT_NAME}.pfx" \
-inkey "${CERT_DIR}/privkey.pem" \
-in "${CERT_DIR}/fullchain.pem" \
-password "pass:$PFX_PASSWORD"
# Upload to Azure Key Vault
az keyvault certificate import \
--vault-name $VAULT_NAME \
--name $CERT_NAME \
--file "/tmp/${CERT_NAME}.pfx" \
--password $PFX_PASSWORD
# Clean up the temporary PFX file
rm "/tmp/${CERT_NAME}.pfx"
echo "Certificate uploaded to Azure Key Vault"
This all-in-one script sets up directories, registers your ACME account, issues the certificate, and deploys it to Nginx — all in a single run. It is great for setting up a brand-new server.
| ? Important: Replace the placeholder values (EAB_KEY, HMAC_KEY, CF_TOKEN, EMAIL, DOMAIN) with your own real credentials before running this script. |
#!/bin/bash
set -e # Stop immediately if any command fails
# ?? Your configuration ??????????????????????????????????????
DOMAIN="demo.example.com"
ACME_SERVER="https://www.freessl.in/acme-new/acme/directory"
EAB_KEY="YOUR_EAB_KEY_HERE"
HMAC_KEY="YOUR_HMAC_KEY_HERE"
EMAIL="you@yourdomain.com"
CF_TOKEN="YOUR_CLOUDFLARE_API_TOKEN"
# ?????????????????????????????????????????????????????????????
export CF_API_TOKEN="$CF_TOKEN"
# Step 1: Create required folders
mkdir -p /etc/dehydrated/{accounts,certs,hooks,chains}
mkdir -p /var/log/dehydrated
# Step 2: Register your ACME account (only needed once)
if [[ -z "$(ls -A /etc/dehydrated/accounts)" ]]; then
echo "Registering account..."
dehydrated --register --accept-terms \
--eab-kid "$EAB_KEY" \
--eab-hmac-key "$HMAC_KEY"
fi
# Step 3: Add your domain to the domains list
[[ ! -f /etc/dehydrated/domains.txt ]] && echo "$DOMAIN" > /etc/dehydrated/domains.txt
# Step 4: Issue the certificate
echo "Issuing certificate for $DOMAIN..."
dehydrated --cron --force --verbose
# Step 5: Deploy to Nginx
CERT_DIR="/etc/dehydrated/certs/${DOMAIN}"
NGINX_SSL_DIR="/etc/nginx/ssl"
mkdir -p "$NGINX_SSL_DIR"
cp "${CERT_DIR}/fullchain.pem" "${NGINX_SSL_DIR}/${DOMAIN}.pem"
cp "${CERT_DIR}/privkey.pem" "${NGINX_SSL_DIR}/${DOMAIN}.key"
chmod 644 "${NGINX_SSL_DIR}/${DOMAIN}.pem"
chmod 600 "${NGINX_SSL_DIR}/${DOMAIN}.key"
# Step 6: Test and reload Nginx
nginx -t && systemctl reload nginx
echo "Certificate deployment completed successfully!"
Check from the certificate file on disk:
openssl x509 -in /etc/dehydrated/certs/demo.example.com/cert.pem \
-noout -dates
Check live from your running web server:
echo | openssl s_client -connect demo.example.com:443 \
-servername demo.example.com 2>/dev/null | openssl x509 -noout -dates
Show the exact number of days remaining:
echo $(( ($(date -d "$(openssl x509 -in /etc/dehydrated/certs/demo.example.com/cert.pem \
-noout -enddate | cut -d= -f2)" +%s) - $(date +%s)) / 86400 )) days
This monitoring script will automatically send you an email if any certificate is getting close to its expiry date. Save it as /usr/local/bin/cert-monitor.sh and make it executable.
#!/bin/bash
ALERT_DAYS=14 # Send alert when fewer than 14 days remain
EMAIL="admin@example.com"
for cert_dir in /etc/dehydrated/certs/*/; do
domain=$(basename "$cert_dir")
cert_file="${cert_dir}/cert.pem"
[[ ! -f "$cert_file" ]] && continue
expiry_date=$(openssl x509 -in "$cert_file" -noout -enddate | cut -d= -f2)
expiry_epoch=$(date -d "$expiry_date" +%s)
days_remaining=$(( ($expiry_epoch - $(date +%s)) / 86400 ))
echo "Domain: $domain | Days remaining: $days_remaining"
if [[ $days_remaining -lt $ALERT_DAYS ]]; then
echo "WARNING: $domain expires in $days_remaining days!" | \
mail -s "SSL Expiry Warning: $domain" $EMAIL
fi
done
Add it to crontab to run daily at 6 AM:
0 6 * * * /usr/local/bin/cert-monitor.sh >> /var/log/dehydrated/monitor.log 2>&1
# Quick list of certificate folders
ls -la /etc/dehydrated/certs/
# Show domain name + expiry date for each
for domain in /etc/dehydrated/certs/*/; do
d=$(basename "$domain")
exp=$(openssl x509 -in "${domain}/cert.pem" -noout -enddate 2>/dev/null | cut -d= -f2)
echo "Domain: $d | Expires: $exp"
done
# Watch the cron renewal log live
tail -f /var/log/dehydrated/cron.log
# Watch the hook script log
tail -f /var/log/dehydrated/cloudflare-hook.log
# View systemd logs (if using systemd timer)
journalctl -u dehydrated.service -f
# Show the last 50 lines from all Dehydrated logs
tail -n 50 /var/log/dehydrated/*.log
Quick one-off backup commands:
# Back up everything (all domains and config)
tar -czf dehydrated-backup-$(date +%Y%m%d).tar.gz /etc/dehydrated/
# Back up only a specific domain
tar -czf demo.example.com-backup-$(date +%Y%m%d).tar.gz \
/etc/dehydrated/certs/demo.example.com/
# Upload backup to another server
scp dehydrated-backup-*.tar.gz backup-server:/backups/
# Or upload to Amazon S3
aws s3 cp dehydrated-backup-*.tar.gz s3://my-backup-bucket/
Automated daily backup script (saves 30 days of history):
#!/bin/bash
BACKUP_DIR="/backup/ssl"
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
# Create today's backup
tar -czf "${BACKUP_DIR}/dehydrated-$(date +%Y%m%d).tar.gz" /etc/dehydrated/
# Delete backups older than 30 days
find "$BACKUP_DIR" -name "dehydrated-*.tar.gz" -mtime +$RETENTION_DAYS -delete
echo "Backup saved to ${BACKUP_DIR}/dehydrated-$(date +%Y%m%d).tar.gz"
Symptom: Certificate issuance fails with a message about the hook or no DNS record being created.
Solution: Check that the hook script file has the execute permission and starts with the correct line.
# Grant execute permission
chmod +x /etc/dehydrated/hooks/cloudflare.sh
# Check the first line — it must say #!/usr/bin/env bash
head -n 1 /etc/dehydrated/hooks/cloudflare.sh
# Test the hook manually
bash -x /etc/dehydrated/hooks/cloudflare.sh \
deploy_challenge "test.com" "token_file" "token_value"
Symptom: The ACME server says the challenge failed because it could not find the DNS record.
Solution: Increase the wait time in your hook script so it gives DNS more time to propagate globally.
nano /etc/dehydrated/hooks/cloudflare.sh
# Inside wait_for_propagation, increase these values:
local max_attempts=6