Self-Hosted Swag to NGINX Migration

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:

Expand to copy the nginx and certbot service blocks
  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/certbot

    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:

Expand to copy the content for the default.conf file
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

Renewal log shows "Hook reported error code 127"

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.

The dry run fails on the ACME challenge

Check the ^~ /.well-known/acme-challenge/ block is present and the webroot volume is mounted into both containers.

Config test fails on the ssl_ecdh_curve line

X25519MLKEM768 needs OpenSSL 3.5+. Remove it from the ssl_ecdh_curve line if your image's OpenSSL is older.

Site still serves the old certificate after renewal

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.

Was this article helpful?
0 out of 0 found this helpful