Configure an Nginx reverse proxy with TLS renewal
Proxy an HTTP application through Nginx, preserve client metadata, obtain a certificate, and test renewal.
Publish an internal web service through a maintained TLS endpoint without exposing the app port.
- Ubuntu Server 24.04 LTS
- Nginx 1.24+
- Certbot 2.x+
- DNS A/AAAA records resolve to the proxy public address.
- Healthy upstream The application answers locally before Nginx is introduced.
curl --fail http://127.0.0.1:3000/health - Ports Inbound TCP 80 and 443 are allowed.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- An Nginx virtual host that accepts public requests for one DNS name and forwards them to a healthy application bound to loopback.
- A maintained TLS endpoint whose certificate is obtained and installed by Certbot, with HTTP redirected to HTTPS.
- A verification chain covering the upstream, Nginx syntax, public HTTP routing, certificate identity and expiry, and simulated renewal.
- The application port remains private while https://app.example.com/health returns the upstream health response through Nginx.
- The upstream receives the original Host, client address and scheme headers needed for logging and secure URL generation.
- The certificate contains the requested hostname and Certbot's dry-run renewal succeeds against the ACME staging service.
Architecture
How the parts fit together
Nginx terminates the public TCP and TLS connection, selects a server block by destination and Host/SNI name, then creates a separate HTTP connection to the loopback application.
- The client resolves the application hostname and connects to Nginx.
- Nginx selects the matching server block and, after certificate deployment, terminates TLS.
- Nginx sends a new HTTP request to the loopback upstream with the reviewed forwarding headers.
- The response returns through Nginx, which handles public connection behavior and certificate presentation.
Assumptions
- The DNS A record, and AAAA record if published, already points to this proxy and is visible from public resolvers.
- TCP 80 and 443 reach Nginx; HTTP-01 validation still requires public port 80 even when normal users are redirected to HTTPS.
- The application already answers its health endpoint on loopback and is configured to trust forwarding headers only from Nginx.
- No CDN or load balancer terminates TLS in front of this host. That architecture requires different client-IP trust and certificate decisions.
Key concepts
- Reverse proxy
- A server that accepts a client request and creates a separate request to an upstream application.
- Virtual host
- An Nginx server block selected by listen address and server_name.
- TLS termination
- Decryption and certificate presentation at Nginx before a separate upstream connection.
- ACME HTTP-01
- A domain-control challenge fetched by the certificate authority over public HTTP on port 80.
- Forwarded headers
- Metadata set by the trusted proxy so the upstream can learn the original host, client address and scheme.
Before you copy
Values used in this guide
{{appHost}}Public DNS hostname for the application.
Example: app.example.com{{upstreamHost}}Private or loopback upstream address.
Example: 127.0.0.1{{upstreamPort}}TCP port on which the application listens.
Example: 3000{{adminEmail}}Operational mailbox for ACME account and expiry notices.
Example: admin@example.comSecurity and production boundaries
- Keep the upstream on loopback or a private network protected by its own policy; TLS at Nginx does not secure an exposed application port.
- Only trust X-Forwarded-For from controlled proxies. Direct clients can forge this header if the application port is reachable.
- Certbot modifies Nginx configuration and stores private keys under /etc/letsencrypt. Back up configuration and restrict root access.
- Do not enable HSTS until HTTPS works for every required subdomain and rollback implications are understood.
Stop before continuing if
- Stop if the upstream health check fails directly; adding Nginx cannot repair an unhealthy application.
- Stop if DNS points elsewhere, public port 80 is unavailable, or an unexpected AAAA record routes validation to another host.
- Stop on any nginx -t error and restore the previous site before reload.
- Stop if Certbot cannot complete its staging or renewal test; a currently valid certificate without renewal is unfinished.
command
Install Nginx and Certbot
Use supported Ubuntu packages.
Why this step matters
Install the proxy, ACME client and Nginx integration from supported Ubuntu repositories so service units and renewal automation share one maintenance path.
What to understand
The Nginx plugin can authenticate the hostname and edit the selected server block for certificate deployment.
Package installation may start Nginx with its default site. Inventory current listeners and configuration before publishing the new hostname.
System changes
- Installs Nginx, Certbot and python3-certbot-nginx; may start and enable package services and timers.
Syntax explained
apt update- Refreshes package metadata.
apt install --yes- Installs the named supported packages and accepts the prompt.
python3-certbot-nginx- Provides Certbot's Nginx authenticator and installer plugin.
sudo apt update && sudo apt install --yes nginx certbot python3-certbot-nginxSetting up nginx ... Setting up python3-certbot-nginx ...
Checkpoint: Confirm installed components
nginx -v && certbot --version && systemctl is-active nginxContinue whenBoth tools report versions and Nginx is active.
Stop whenAPT is incomplete or Nginx fails before any custom site exists.
Security notes
- Review default sites and listeners; installation alone should not be mistaken for a hardened public service.
Alternatives
- Use the distribution packages instead of piping a third-party installer into a shell.
Stop conditions
- Do not continue while package or service state is failed.
verification
Verify the upstream directly
Record the expected status and response before adding the proxy.
Why this step matters
Establish application health directly so later failures can be attributed to Nginx, DNS or TLS rather than an already broken upstream.
What to understand
The request uses loopback and the exact health path the proxy will publish. --fail makes HTTP 400 and 500 responses fail the command.
Capture status, headers and deterministic response body. If the application has no health endpoint, create one that verifies only safe local dependencies.
System changes
- No persistent changes; sends one local HTTP request.
Syntax explained
--fail- Returns a non-zero exit code for HTTP error status responses.
--silent --show-error- Suppresses progress while preserving error diagnostics.
--include- Prints response headers with the body.
Values stay on this page and are never sent or saved.
curl --fail --silent --show-error --include http://{{upstreamHost}}:{{upstreamPort}}/healthHTTP/1.1 200 OK
{"status":"ok"}Checkpoint: Require a healthy local response
curl --fail --silent --show-error --include http://{{upstreamHost}}:{{upstreamPort}}/healthContinue whenHTTP 200 and the application's expected health payload.
Stop whenConnection fails, status is not successful, or response comes from the wrong application.
Security notes
- Keep the upstream bound to loopback unless a private network design explicitly requires otherwise.
Alternatives
- Use a Unix-domain socket health request where the server supports it.
Stop conditions
- Fix the application before introducing the proxy.
config
Create the virtual host
Forward the original host, client address, and protocol to a loopback or private upstream.
Why this step matters
A dedicated server block binds one hostname to one upstream and sets trusted metadata at the proxy boundary.
What to understand
server_name controls host selection, while proxy_pass creates the upstream request. The trailing URI behavior of proxy_pass matters; this baseline preserves the original request URI.
Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto let a correctly configured application generate URLs and logs from the original request context.
System changes
- Creates /etc/nginx/sites-available/app.example.com as a new HTTP virtual host.
Syntax explained
listen 80 / [::]:80- Accepts IPv4 and IPv6 HTTP where those addresses exist.
server_name- Selects this server block for the application hostname.
location /- Applies the proxy to every request path.
proxy_pass- Forwards requests to the private upstream.
proxy_http_version 1.1- Uses HTTP/1.1 for the upstream connection.
proxy_set_header- Replaces client-supplied forwarding metadata with values chosen by Nginx.
/etc/nginx/sites-available/{{appHost}}Values stay on this page and are never sent or saved.
server {
listen 80;
listen [::]:80;
server_name {{appHost}};
location / {
proxy_pass http://{{upstreamHost}}:{{upstreamPort}};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Site file written
Checkpoint: Review rendered host and upstream
Continue whenNo variables remain; server_name matches public DNS and proxy_pass matches the proven loopback listener.
Stop whenThe upstream is public, the hostname is wrong, or another enabled server block claims the same name.
Security notes
- The application must trust these headers only from Nginx, not from arbitrary clients.
Alternatives
- Use an upstream group when multiple application instances require balancing.
Stop conditions
- Do not enable a file with unresolved variables or a duplicate server_name.
command
Enable and validate the site
Stop on any nginx -t failure.
Why this step matters
Enable the reviewed site through Ubuntu's sites-enabled convention and require Nginx's full configuration test before any reload.
What to understand
The symbolic link keeps one editable source in sites-available. nginx -t parses all includes and checks referenced files.
If the link already exists, inspect it rather than replacing it blindly. Remove the default site only after confirming it is not required.
System changes
- Creates a symbolic link under /etc/nginx/sites-enabled; does not reload Nginx until a later step.
Syntax explained
ln -s- Creates a symbolic link to the reviewed site file.
nginx -t- Validates complete Nginx syntax and referenced files.
&&- Runs validation only after link creation succeeds.
Values stay on this page and are never sent or saved.
sudo ln -s /etc/nginx/sites-available/{{appHost}} /etc/nginx/sites-enabled/{{appHost}} && sudo nginx -tsyntax is ok configuration file test is successful
Checkpoint: Require successful syntax validation
sudo nginx -tContinue whensyntax is ok and configuration file test is successful.
Stop whenAny emerg, duplicate, missing-file or syntax diagnostic appears.
Security notes
- Validation is read-only, but enabling a link changes what the next reload will publish.
Alternatives
- Use an atomic configuration-management symlink deployment with the same nginx -t gate.
Stop conditions
- Never reload after a failed nginx -t.
command
Reload and test HTTP
Reload without dropping connections and test the public hostname.
Why this step matters
Apply the validated HTTP proxy without dropping established connections, then test the public hostname before involving certificate issuance.
What to understand
A reload starts new workers with the new configuration and lets old workers finish active requests.
The hostname test exercises Nginx server selection. Use --resolve during pre-DNS testing only when you understand that it overrides normal resolution for the one command.
System changes
- Reloads Nginx so the new HTTP virtual host begins handling requests.
Syntax explained
systemctl reload nginx- Applies configuration gracefully.
curl --fail --include- Requires a successful public HTTP response and prints its headers.
Values stay on this page and are never sent or saved.
sudo systemctl reload nginx && curl --fail --include http://{{appHost}}/healthHTTP/1.1 200 OK Server: nginx
Checkpoint: Prove public HTTP routing
curl --fail --include http://{{appHost}}/healthContinue whenHTTP 200 from Nginx with the same application health payload as the direct baseline.
Stop whenThe wrong virtual host responds, status differs, or public DNS reaches another address.
If this step fails
The hostname returns Nginx's default page.
Likely causeDNS, Host selection or an enabled default server block does not match the intended virtual host.
getent ahosts app.example.comsudo nginx -T | grep -nE 'listen|server_name'
ResolutionCorrect DNS and server_name selection, validate and reload before requesting a certificate.
Security notes
- Keep the upstream port blocked publicly even while HTTP on Nginx is open.
Alternatives
- Use curl --resolve for a controlled pre-DNS check, then repeat with normal public DNS.
Stop conditions
- Do not request a certificate until public HTTP reaches the correct virtual host.
command
Obtain and deploy the certificate
Provide an operational renewal address and review Certbot's generated redirect.
Why this step matters
Obtain and deploy a hostname-valid certificate only after public DNS and HTTP routing are proven, reducing failed ACME attempts and unexpected Nginx edits.
What to understand
The Nginx plugin performs the challenge and installs certificate references in the matching server block. --redirect changes ordinary HTTP requests to HTTPS.
The email belongs to the ACME account for operational notices. Review Certbot's configuration diff and certificate names after completion.
System changes
- Creates ACME account and certificate state under /etc/letsencrypt and modifies Nginx configuration to serve HTTPS and redirect HTTP.
Syntax explained
--nginx- Uses the Nginx plugin for authentication and installation.
-d- Requests the exact DNS identifier for the certificate.
--redirect- Configures HTTP-to-HTTPS redirection for the authenticated virtual host.
--email- Sets the ACME account contact address.
--agree-tos- Accepts the ACME server subscriber agreement non-interactively.
--no-eff-email- Does not share the account email with EFF.
Values stay on this page and are never sent or saved.
sudo certbot --nginx -d {{appHost}} --redirect --email {{adminEmail}} --agree-tos --no-eff-emailSuccessfully deployed certificate for app.example.com
Checkpoint: Inspect certificate and Nginx after deployment
sudo certbot certificates && sudo nginx -tContinue whenThe certificate lists the exact hostname, has a future expiry and Nginx validation succeeds.
Stop whenAn unexpected domain is present, private-key paths are missing, or nginx -t fails.
If this step fails
Certbot cannot find a matching server block.
Likely causeserver_name does not exactly match the requested -d hostname or the site is not enabled.
sudo nginx -T | grep -n 'server_name app.example.com'readlink -f /etc/nginx/sites-enabled/app.example.com
ResolutionCorrect and enable the HTTP server block, validate, reload and repeat public HTTP testing before retrying.
Security notes
- Private keys under /etc/letsencrypt must remain root-controlled and included in secure recovery planning.
Alternatives
- Use certonly with webroot or DNS plugins when automatic Nginx editing is not desired.
Stop conditions
- Stop if the ACME challenge reaches the wrong host or Certbot leaves Nginx invalid.
verification
Dry-run renewal
A certificate is not ready until automated renewal succeeds.
Why this step matters
A certificate that works today but cannot renew is a scheduled outage, so test the saved renewal configuration immediately.
What to understand
renew --dry-run uses the Let's Encrypt staging server by default, obtains test certificates and does not replace live certificate files.
The test may temporarily reload web-server configuration. Inspect the systemd timer or scheduled task that will invoke future renewal.
System changes
- Uses temporary ACME challenge state and may reload Nginx; does not save a production certificate.
Syntax explained
renew- Selects installed certificates using their saved renewal configuration.
--dry-run- Tests renewal against staging without storing the test certificate.
sudo certbot renew --dry-runCongratulations, all simulated renewals succeeded
Checkpoint: Require simulated renewal success
sudo certbot renew --dry-run && systemctl list-timers '*certbot*' --no-pagerContinue whenAll simulated renewals succeed and a future automated invocation is visible.
Stop whenAny authenticator, challenge, hook or reload fails.
Security notes
- Do not repeatedly force production renewals for testing; certificate authorities enforce rate limits.
Alternatives
- Use certbot reconfigure for supported renewal parameter changes, then repeat the dry run.
Stop conditions
- Do not finish the change with a failed dry run or absent renewal schedule.
verification
Inspect the external endpoint
Confirm HTTPS, hostname, expiry, and application response.
Why this step matters
An external TLS client proves the public route, SNI hostname, certificate chain, expiry and application response together.
What to understand
curl validates the certificate against the client trust store and requires a successful application response.
openssl s_client supplies the SNI name and x509 prints the leaf subject and validity window without dumping private material.
System changes
- No persistent changes; creates public HTTPS test connections.
Syntax explained
curl --fail --include- Validates TLS and requires a successful HTTP status while showing headers.
s_client -connect host:443- Opens a diagnostic TLS connection to the endpoint.
-servername- Sends the SNI hostname used for virtual-host certificate selection.
x509 -noout -subject -dates- Prints only leaf identity and validity fields.
Values stay on this page and are never sent or saved.
curl --fail --include https://{{appHost}}/health && openssl s_client -connect {{appHost}}:443 -servername {{appHost}} </dev/null 2>/dev/null | openssl x509 -noout -subject -datesHTTP/2 200 subject=CN = app.example.com notAfter=...
Checkpoint: Validate public identity and health
curl --fail --include https://{{appHost}}/health && openssl s_client -connect {{appHost}}:443 -servername {{appHost}} </dev/null 2>/dev/null | openssl x509 -noout -subject -datesContinue whenHTTPS returns the application health response and the leaf certificate covers the hostname with a future notAfter date.
Stop whenTrust validation fails, the wrong certificate is served, or the app response differs from the direct baseline.
Security notes
- Test from outside the server network and through normal DNS to avoid split-horizon false confidence.
Alternatives
- Use an independent monitored probe as continuing verification after this manual test.
Stop conditions
- Do not declare success based only on Nginx service state or a local curl with certificate verification disabled.
Finish line
Verification checklist
sudo nginx -tConfiguration test succeeds.curl --fail https://app.example.com/healthReturns the application health response.sudo certbot renew --dry-runAll simulated renewals succeed.Recovery guidance
Common problems and safe checks
Nginx returns 502 Bad Gateway.
Likely causeThe upstream is stopped, bound to another address or port, or Nginx is denied access.
curl --fail --include http://127.0.0.1:3000/healthsudo ss -lntpsudo journalctl -u nginx -n 50 --no-pager
ResolutionRestore the upstream or correct proxy_pass to the verified listener; do not open the application port publicly.
Certbot reports an unauthorized HTTP-01 challenge.
Likely causeDNS resolves to another address, port 80 is blocked, IPv6 points elsewhere, or another server block handles the hostname.
getent ahosts app.example.comcurl -I http://app.example.com/sudo nginx -T | grep -n 'server_name app.example.com'
ResolutionCorrect public DNS, firewall and virtual-host selection, wait for DNS propagation, then retry without repeatedly consuming production rate limits.
HTTPS works but the application redirects to HTTP or logs the proxy address as the client.
Likely causeThe upstream does not trust Nginx forwarding headers or Nginx did not set the required header.
Inspect the rendered Nginx server blockCheck upstream request logs for Host and X-Forwarded-ProtoConfirm the app trusts only the proxy address
ResolutionSet the headers in Nginx and configure the framework's trusted-proxy list narrowly.
certbot renew --dry-run fails while the current site remains valid.
Likely causeThe authenticator path, DNS, port 80, plugin or saved renewal parameters changed after issuance.
sudo certbot certificatessudo systemctl list-timers '*certbot*'sudo journalctl -u certbot.service -n 50 --no-pager
ResolutionRepair the saved authentication path and repeat a dry run; do not hand-edit renewal files without the documented Certbot reconfigure process.
Reference
Frequently asked questions
Why test the upstream before Nginx?
It creates a known-good baseline. A later 502 can then be localized to the listener, proxy configuration or access controls instead of the application itself.
Why keep port 80 open after redirecting to HTTPS?
HTTP-01 renewal still reaches port 80. The Nginx plugin can serve the challenge while redirecting ordinary requests.
Should proxy_pass use HTTPS?
Use HTTPS for an upstream crossing an untrusted network. For a loopback listener, strict local binding and host security are usually the primary boundary.
Recovery
Rollback
Disable the new virtual host and restore the previous proxy.
- Restore the saved site or remove its sites-enabled link.
- Run sudo nginx -t.
- Reload Nginx only after validation succeeds.
- Revoke the certificate only when issuance or its private key is compromised.
Evidence