Nginx redirect loop when using proxy_pass over SSL

We are using Nginx as a load balancer for our Rails app. Since we're are moving to a multicloud hosting solution, we want our load balancer to start using SSL for every connection when it's forwarding requests to frontends, since some of them might go over the internet.

The problem I'm facing is that the non https page created a redirect loop. This seems to be caused by the X-Forwarded-Proto header not being set properly. So when rails gets a request on http it thinks it's an https request even though it's not, so it redirects it to http, and it thinks it an https request and so on.

No amount of Googling seems to help me fix that problem. So I would like to know:

  • Is this supported by NGINX (I think it is)
  • Is there something wrong with my config
  • Is there something conceptually wrong with my approach

Thanks!

user www-data;
worker_processes  2;

events {
    worker_connections  1024;
    use epoll;
}


http {
    passenger_root /opt/ruby-enterprise/lib/ruby/gems/1.8/gems/passenger-3.0.2;
    passenger_ruby /opt/ruby-enterprise/bin/ruby;
    passenger_pool_idle_time 0;
    passenger_max_pool_size 20;  # Over all apps
    passenger_min_instances 5;   # Over each app
    passenger_use_global_queue on;
    rails_env production;

    include       mime.types;
    default_type  application/octet-stream;

    log_format  main  '"$remote_addr", "$remote_user", "$time_local", "$request", '
                      '"$uid_got", "$uid_set", "$status", "$body_bytes_sent", "$http_referer", '
                      '"$http_user_agent", "$http_x_forwarded_for"';

    ## Compression
    gzip on;
    gzip_http_version 1.0;
    gzip_comp_level 2;
    gzip_proxied any;
    gzip_min_length  1100;
    gzip_buffers 16 8k;
    gzip_types text/plain text/css application/x-javascript text/xml application/xml application/xml+rss text/javascript;
    # Some version of IE 6 don't handle compression well on some mime-types, so just disable for them
    gzip_disable "MSIE [1-6].(?!.*SV1)";
    # Set a vary header so downstream proxies don't send cached gzipped content to IE6
    gzip_vary on;

    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;

    client_max_body_size 20M;

    server {
           listen    80 default;
       return        404;
    }

    server {
           listen        80;
           server_name   MYDOMAIN.com;
           rewrite ^(.*) http://www.MYDOMAIN.com$1 permanent;
    }

    server {
           listen 80;
           server_name www.MYDOMAIN.com;
           root        /u/apps/MYDOMAIN_marketing/current/public;

           passenger_enabled on;

           userid on;
           userid_domain MYDOMAIN.com;
           userid_expires max;

           access_log /u/apps/MYDOMAIN_marketing/shared/log/nginx/access.log main;
           error_log  /u/apps/MYDOMAIN_marketing/shared/log/nginx/error.log info;
    }

    #####################

    upstream app_backend {
           server www01:8000;
           server www02:8000;
           server www03:8000;
           server www04:8000;
           server www05:8000;
           server www06:8000;
    }

    server {
           listen 80;
           server_name *.MYDOMAIN.com;
           root /u/apps/MYDOMAIN/current/public;

           userid on;
           userid_domain MYDOMAIN.com;
           userid_expires max;

           access_log /u/apps/MYDOMAIN/shared/log/nginx/lb_access.log main;
           error_log  /u/apps/MYDOMAIN/shared/log/nginx/lb_error.log info;

           if (-f $document_root/system/maintenance.html) {
                    # I don't know how to get NGINX to both show a page and give a return code.
                    # So just return 503 with a generic error page.
                    return 503;
           }

           location / {
                    ## General Rails error page stuff
                    error_page 404              /404.html;
                    error_page 422              /422.html;
                    error_page 500 502 503 504  /500.html;
                    error_page 403              /403.html;

            # If the file exists then stop here. Saves 4 more stats and some
            # rewrites.
            if (-f $request_filename) { 
                  break; 
                }

                    # Rails page caching
                    if (-f $request_filename/index.html) {
                          rewrite (.*) $1/index.html break;
                    }

                    if (-f $request_filename.html) {
                          rewrite (.*) $1.html break;
                    }

                    # If it hasn't been handled above, and isn't a static file
                    # then send to passenger.
                    proxy_pass https://app_backend;
                    proxy_connect_timeout 1;
                    ##proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;

                    proxy_set_header X-Real-IP $remote_addr;
                    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                    proxy_set_header Host $http_host;
                    proxy_set_header X-Forwarded-Proto http;
           }
    }

    server {
           ssl on;
           listen 8000;
           server_name *.MYDOMAIN.com;
           root /u/apps/MYDOMAIN/current/public;

           passenger_enabled on;

           access_log /u/apps/MYDOMAIN/shared/log/nginx/access.log main;
           error_log  /u/apps/MYDOMAIN/shared/log/nginx/error.log info;
    }

    ########################

    # SSL configuration from:                                                         
    # http://tumblelog.jauderho.com/post/121851623/nginx-and-stronger-ssl
    # http://articles.slicehost.com/2007/12/19/ubuntu-gutsy-nginx-ssl-and-vhosts
    ssl_certificate      MYDOMAIN.com.combined.crt;
    ssl_certificate_key  MYDOMAIN.com.key;
    ssl_prefer_server_ciphers on;
    ssl_protocols        SSLv3 TLSv1;
    ssl_session_cache    shared:SSL:2m;
    ssl_ciphers          ALL:!ADH:RC4+RSA:+HIGH:+MEDIUM:-LOW:-SSLv2:-EXP;

    # This is necessary to catch random crap thrown at us during the
    # SecurityMetrics scans before it hits Passenger.  Without this,
    # Passenger becomes confused and stops serving requests.

    server {
           listen 443 default;
       ssl on;
       return 404;
    }

    server {
           # If we're willing to log secure and non-secure together,
           # we can probably just merge this with the config above.

           listen 443;
       server_name *.MYDOMAIN.com;
       ssl on;

           root /u/apps/MYDOMAIN/current/public;

           userid on;
           userid_domain MYDOMAIN.com;
           userid_expires max;

           access_log /u/apps/MYDOMAIN/shared/log/nginx/lb_secure_access.log main;
           error_log  /u/apps/MYDOMAIN/shared/log/nginx/lb_secure_error.log info;

           if (-f $document_root/system/maintenance.html) {
                    # I don't know how to get NGINX to both show a page and give a return code.
                    # So just return 503 with a generic error page.
                    return 503;
           }

           location / {
                    ## General Rails error page stuff
                    error_page 404              /404.html;
                    error_page 422              /422.html;
                    error_page 500 502 503 504  /500.html;
                    error_page 403              /403.html;

            # If the file exists then stop here. Saves 4 more stats and some
            # rewrites.
            if (-f $request_filename) { 
                  break; 
                }

                    # Rails page caching
                    if (-f $request_filename/index.html) {
                          rewrite (.*) $1/index.html break;
                    }

                    if (-f $request_filename.html) {
                          rewrite (.*) $1.html break;
                    }

                    # If it hasn't been handled above, and isn't a static file
                    # then send to passenger.
                    proxy_pass https://app_backend;
                    proxy_connect_timeout 1;
                    ##proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;

                    proxy_set_header X-Real-IP $remote_addr;
                    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                    proxy_set_header Host $http_host;
                    proxy_set_header X-Forwarded-Proto https;
           }
    }
}

Solution 1:

As it turns out, Passenger and Nginx don't work like most other. You have to manually force the HTTPS header in your nginx.conf file.

server {
  listen 8000;
  ssl on;
  server_name *.pagerduty.com *.supportduty.com;
  root /u/apps/pagerduty/current/public;

  set $my_https "off";
  if ($http_x_forwarded_proto = "https") {
      set $my_https "on";
  }

  passenger_enabled on;
  passenger_set_cgi_param HTTPS $my_https;

  access_log /u/apps/pagerduty/shared/log/nginx/access.log main;
  error_log  /u/apps/pagerduty/shared/log/nginx/error.log info;
}

Solution 2:

The if statements may not set the proxy headers properly.

See : http://wiki.nginx.org/IfIsEvil -> "Directive if has problems when used in location context, in some cases it doesn't do what you expect but something completely different instead."

The if statements could be replaced by try_files statements which is more efficient.

Your conf file should look like this :

server {
    listen 80;
    server_name *.MYDOMAIN.com;
    root /u/apps/MYDOMAIN/current/public;

    userid on;
    userid_domain MYDOMAIN.com;
    userid_expires max;

    access_log /u/apps/MYDOMAIN/shared/log/nginx/lb_access.log main;
    error_log  /u/apps/MYDOMAIN/shared/log/nginx/lb_error.log info;

    location $document_root/system/maintenance.html {
        return 503;
    }

    # Nginx will try thoses locations and will stop after the first success
    try_files $uri $uri.html $uri/index.html @passenger

    location / {
        ## General Rails error page stuff
        error_page 404         /404.html;
        error_page 422         /422.html;
        error_page 500 502 503 504  /500.html;
        error_page 403         /403.html;
    }

    location @passenger {
    # Here you're sure that if the request goes to the backends, the header will be set. If this doesn't work, then you should consider charging the ruby app.
        proxy_pass https://app_backend;
        proxy_connect_timeout 1;

        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto http;
    }
}