This article covers moving an existing SWAG-based self-hosted install to the nginx + certbot deployment. If you're setting up a brand new server instead, follow Self Hosted: Standard Setup Guide from the start - this article only covers the migration path.
The config and certificate paths stay the same, /var/www/hudu2/config/nginx/site-confs/ and /var/www/hudu2/config/etc/letsencrypt/, since the new setup mounts into the same locations SWAG already used.
Your existing certificate carries over as-is - no re-issuance needed. Renewal now uses webroot validation instead of SWAG's standalone validation, so nginx no longer has to stop to renew.
Stop the SWAG service
cd ~/hudu2 sudo docker compose stop letsencrypt sudo docker compose rm -f letsencrypt
Create the webroot directory
This is where certbot drops its validation files and nginx serves them from:
sudo mkdir -p /var/www/hudu2/config/certbot-webroot
Don't reuse SWAG's old /var/www/hudu2/config/www/ directory - it's SWAG-internal and isn't mounted into the new nginx service.
Edit docker-compose.yml
Remove the letsencrypt service block, and add these two service blocks in its place:
nginx:
image: nginx:1.31.8-alpine
container_name: nginx
restart: always
ports:
- 80:80
- 443:443
volumes:
- /var/www/hudu2/config:/config:ro
- /var/www/hudu2/config/nginx/site-confs:/etc/nginx/conf.d:ro
- /var/www/hudu2/config/certbot-webroot:/var/www/certbot:ro
- /var/www/hudu2/public:/var/www/hudu2/public:ro
depends_on:
- app
certbot:
image: certbot/certbot:v5.7.0
container_name: certbot
volumes:
- /var/www/hudu2/config/etc/letsencrypt:/config/etc/letsencrypt
- /var/www/hudu2/config/certbot-webroot:/var/www/certbot/etc/letsencrypt/... instead of /config/etc/letsencrypt. Check before moving on:sudo grep -E 'archive_dir|cert|privkey|chain|fullchain' /var/www/hudu2/config/etc/letsencrypt/renewal/<your domain>.conf
If any of those lines point at /etc/letsencrypt/... rather than /config/etc/letsencrypt/..., add a second mount to the certbot service so certbot can resolve both paths:
- /var/www/hudu2/config/etc/letsencrypt:/etc/letsencrypt
Leave the existing /config/etc/letsencrypt mount in place - this is additive, not a replacement.
Nothing here needs customizing - domain is handled entirely in default.conf.
Replace default.conf
This file already exists from SWAG, so this is an edit, not a new file. Navigate to the folder:
cd /var/www/hudu2/config/nginx/site-confs/
Back up the file and confirm the certificate symlinks before you change anything:
sudo cp default.conf default.conf.swag-backup sudo ls -l /var/www/hudu2/config/keys/
You should see cert.crt and cert.key as symlinks pointing into letsencrypt/. The config below keeps those paths, so there's no domain to substitute. If they're regular files instead of symlinks, don't use them - point ssl_certificate and ssl_certificate_key at /config/etc/letsencrypt/live/<your domain>/fullchain.pem and privkey.pem instead.
Replace the contents of default.conf with the following:
# Applies to all server blocks below
client_max_body_size 0;
server_tokens off;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
server_name _;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
root /var/www/hudu2/public;
index index.html;
ssl_certificate /config/keys/cert.crt;
ssl_certificate_key /config/keys/cert.key;
add_header Strict-Transport-Security "max-age=63072000";
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
location = /.well-known/oauth-authorization-server {
limit_except GET { deny all; }
client_max_body_size 1k;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app:3000;
}
location = /.well-known/oauth-protected-resource {
limit_except GET { deny all; }
client_max_body_size 1k;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app:3000;
}
location /mcp {
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
gzip off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app:3000;
}
location ~ /\. {
deny all;
}
location ~* ^.+\.(rb|log)$ {
deny all;
}
location /cable {
proxy_pass http://app:3000/cable;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 240s;
proxy_send_timeout 240s;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass_request_headers on;
proxy_buffering off;
proxy_redirect off;
break;
}
location / {
try_files $uri @rails;
}
location @rails {
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
proxy_buffers 32 4k;
proxy_connect_timeout 240;
proxy_headers_hash_bucket_size 128;
proxy_headers_hash_max_size 1024;
proxy_http_version 1.1;
proxy_read_timeout 240;
proxy_redirect http:// $scheme://;
proxy_send_timeout 240;
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Early-Data $ssl_early_data;
proxy_set_header Host $host;
proxy_set_header Proxy "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Method $request_method;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-Ssl on;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://app:3000;
}
}
include /config/nginx/proxy-confs/*.subdomain.conf;
proxy_cache_path cache/ keys_zone=auth_cache:10m; The first two lines apply to both server blocks. server_tokens off hides the nginx version number from response headers and error pages, and client_max_body_size 0 removes nginx's default 1 MB upload limit so large file and photo uploads to Hudu aren't rejected with a 413 error. The two OAuth discovery endpoints keep their own 1k limit.
Validate the config, then start nginx:
cd ~/hudu2 sudo docker compose run --rm --entrypoint nginx nginx -t sudo docker compose up -d nginx
Visit your domain to confirm HTTPS loads with no certificate warning.
If nginx won't start, check the symlink chain from inside the container:
sudo docker compose run --rm --entrypoint sh nginx -c 'readlink -f /config/keys/cert.crt && ls -l /config/keys/cert.crt'
A failure here means the /config mount is missing or partial.
Remove SWAG's leftover renewal hook scripts
Certbot automatically runs anything in renewal-hooks/pre/, renewal-hooks/post/, and renewal-hooks/deploy/ on every renewal. SWAG populates all three, and none of them work outside the SWAG image. List them first:
sudo ls -la /var/www/hudu2/config/etc/letsencrypt/renewal-hooks/{pre,post,deploy}/Then remove them:
sudo rm -f /var/www/hudu2/config/etc/letsencrypt/renewal-hooks/pre/10-nginx sudo rm -f /var/www/hudu2/config/etc/letsencrypt/renewal-hooks/post/10-nginx sudo rm -f /var/www/hudu2/config/etc/letsencrypt/renewal-hooks/deploy/10-default
On older installs, SWAG's hooks can also show up as pre_hook, post_hook, or renew_hook lines written directly into the renewal file, rather than as scripts under renewal-hooks/. Deleting the scripts above won't remove these.
Check the renewal file:
sudo grep -E 'pre_hook|post_hook|renew_hook' /var/www/hudu2/config/etc/letsencrypt/renewal/<your domain>.conf
If any are present, remove those lines from the file - the renewal-hooks/ directories used earlier in this guide handle hooks going forward, so these inline entries are redundant leftovers. Empty renewal-hooks/ directories and non-symlink files under config/keys/ are expected on installs this old and are not, on their own, a sign of a problem.
Removing deploy/10-default is safe - certbot updates the live/ symlinks itself on renewal. Confirm after your first real renewal:
sudo readlink -f /var/www/hudu2/config/keys/cert.crt sudo openssl x509 -enddate -noout -in /var/www/hudu2/config/keys/cert.crt
Confirm renewal works
This dry run validates against Let's Encrypt's staging environment, so it won't affect your real certificate or count against rate limits. --config-dir must match the mount path above:
sudo docker compose run --rm certbot renew \ --config-dir /config/etc/letsencrypt \ --webroot -w /var/www/certbot \ --dry-run
nginx stays running the whole time - no downtime. If it fails, check that the ^~ /.well-known/acme-challenge/ block is in place and the webroot volume is mounted into both containers.
Set up automatic renewal
Certbot certificates are valid for 90 days. Add a cron job:
crontab -e
Add this line:
0 3 * * * cd ~/hudu2 && docker compose run --rm certbot renew --config-dir /config/etc/letsencrypt --webroot -w /var/www/certbot >> /var/log/hudu-certbot-renew.log 2>&1; docker compose exec -T nginx nginx -s reload >> /var/log/hudu-certbot-renew.log 2>&1
Runs daily but only acts within 30 days of expiry. The -T flag is required - cron has no TTY and the reload fails silently without it.
Test the reload line as cron will run it:
sudo docker compose exec -T nginx nginx -s reload && echo reloaded
After your first real renewal, confirm the renewal file now records webroot:
sudo grep -E 'authenticator|webroot' /var/www/hudu2/config/etc/letsencrypt/renewal/<your domain>.conf
You should see authenticator = webroot and a webroot_path pointing at /var/www/certbot. Until then, keep the --webroot -w flags on the cron line.
Keeping nginx up to date
The nginx service uses the nginx:1.31.8-alpine tag, which always points to the latest patch release in the 1.31 line. Docker doesn't download new releases on its own, though - the container keeps running the image it was created from until you pull the updated image and recreate the container.
nginx -s reload) or running docker compose restart does not update nginx. Both reuse the existing container and image, so the nginx version stays the same.First, confirm the nginx service in docker-compose.yml uses this image tag:
image: nginx:1.31.8-alpine
If it's pinned to a specific patch version instead, such as nginx:1.31.0-alpine, update it to the tag above so the next pull picks up the latest patch release.
Then choose one of the options below, depending on whether you want to update only nginx or every service in your compose file.
Option 1: Update nginx only
Use this when you only want to pick up a new nginx release and leave Hudu and the other services exactly as they are:
cd ~/hudu2 sudo docker compose pull nginx sudo docker compose down nginx sudo docker compose up -d nginx
Option 2: Update all services
Use this when you want to update everything at once. Running docker compose pull without a service name pulls the latest image for every service in docker-compose.yml, and docker compose up -d then recreates any service whose image changed. --remove-orphans also cleans up containers for services that are no longer in the file, such as the old SWAG container.
cd ~/hudu2 sudo docker compose pull sudo docker compose down nginx sudo docker compose up -d --remove-orphans
With either option, expect a brief outage between the down and up commands while the nginx container is recreated.
Troubleshooting
A SWAG renewal hook script is still in place - it can't run outside the SWAG image. Renewal still completes; remove the three scripts above to clear the noise.
Check the ^~ /.well-known/acme-challenge/ block is present and the webroot volume is mounted into both containers.
X25519MLKEM768 needs OpenSSL 3.5+. Remove it from the ssl_ecdh_curve line if your image's OpenSSL is older.
Compare what's served to what's on disk:
echo | openssl s_client -connect localhost:443 -servername <your domain> 2>/dev/null | openssl x509 -enddate -noout sudo openssl x509 -enddate -noout -in /var/www/hudu2/config/keys/cert.crt
If the served date is older, the reload step didn't run - test it manually with the -T flag.
Reloading and restarting both reuse the existing container, so the nginx version doesn't change. Pull the updated image and recreate the container - see Keeping nginx up to date.
Common on installs that have been through several past migrations. Add a second certbot volume mount for /etc/letsencrypt (see Edit docker-compose.yml) rather than editing the paths inside the renewal file directly.
A pre_hook, post_hook, or renew_hook line is still present directly inside the renewal .conf file from a previous container generation. Remove those lines - see Remove SWAG's leftover renewal hook scripts.