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 Standard Setup Guide for Self-Hosted 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-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/certbotNothing 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:
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;
client_max_body_size 0;
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;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
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.
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.