Install nginx on Ubuntu with a production baseline
Install nginx, replace the default site, set a minimal server identity, validate config, restrict firewall access, and prove restart behavior.
Create a clean nginx host ready for explicit server blocks without exposing sample content or unmanaged listeners.
- Ubuntu Server 24.04 LTS
- nginx 1.24+
- Console and recovery access Keep an independent console session and a copy of the current nginx configuration before changing listeners, modules, or access rules.
- DNS and network plan Resolve every production hostname to the intended public address and list the exact management and client networks that require access.
getent ahosts {{domain}} && ip route show - Port ownership Confirm that TCP port 80 is free or already belongs to the intended nginx instance.
sudo ss -lntp 'sport = :80' - Rollback artifact Snapshot the host or create an archive of the active configuration and web roots before the first material change.
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 Ubuntu-maintained nginx service with recorded package provenance, port ownership, boot state, complete include map, firewall rule, and recovery archive.
- A minimal global baseline that reduces version disclosure, bounds request bodies, replaces distribution sample content with a root-owned deterministic health page, and leaves explicit server blocks for the next stage.
- A parser-before-reload and named-listener validation workflow with local HTTP evidence, journal review, restart rehearsal guidance, and rollback that distinguishes a fresh install from retained prior state.
- nginx is enabled/active, owns only the intended TCP/80 listeners, its complete configuration parses, and `/` returns the expected health marker.
- The exact Ubuntu package/runtime version is recorded, `server_tokens off` is active, the request-body limit is intentional, and sample content is absent.
- Operators can explain enabled includes, default-server behavior, worker identity, firewall scope, logs, update path, and the next steps required before applications or TLS.
Architecture
How the parts fit together
Ubuntu packages nginx as a systemd service with `/etc/nginx/nginx.conf`, included `conf.d/*.conf`, and available/enabled site symlinks. A small global fragment sets only baseline disclosure/body behavior. The packaged default server temporarily serves a root-owned health marker from `/var/www/html`. `nginx -t` parses the active tree and `nginx -T` reveals all includes before reload. UFW permits public HTTP only after listener ownership is known. Explicit rejecting/default server blocks and tenant roots are intentionally deferred to the next guide.
- Inventory release, addresses/routes, listeners, packages, firewall, failed units, DNS, storage, and recovery access.
- Archive existing nginx/web-root state or explicitly record a fresh host.
- Install signed Ubuntu packages and record package plus runtime versions and any auto-started listener.
- Enable a minimal baseline fragment and replace the actual first-priority index file with a deterministic marker.
- Parse/dump the complete config, review default/listener scope, then add the exact firewall profile.
- Enable/reload nginx, request the local listener, inspect logs/service state, and retain rollback evidence.
Assumptions
- The host is Ubuntu Server 24.04 LTS using approved Ubuntu repositories, with independent console recovery and control of upstream firewalls.
- No undocumented process, container publisher, socket unit, Apache instance, or prior nginx owns TCP/80/443 or the intended web root.
- Public HTTP is required for later redirect/ACME/site use; internal services should bind privately and use source-restricted rules.
- This commissioning page is temporary. It is not an application health check, virtual-host isolation policy, TLS endpoint, or production content deployment.
- Configuration management is paused or coordinated so it will not overwrite manual evidence/files before the pattern is incorporated into its source.
Key concepts
- server block
- nginx configuration context that selects addresses/names and contains routing/content policy for a virtual server.
- default server
- The server block selected for an address when no server_name matches; it should not expose another tenant.
- master/worker model
- A privileged master opens listeners/logs and manages less-privileged workers that process requests.
- expanded configuration
- The active nginx configuration after every include is read, printed by `nginx -T` for audit.
- graceful reload
- A validated config activation where new workers start with new policy while old workers finish active requests.
- client_max_body_size
- A request-body ceiling that can reject oversized uploads with 413 and must match application needs.
Before you copy
Values used in this guide
{{domain}}Representative intended DNS name checked by prerequisites before later explicit server blocks.
Example: web01.example.netSecurity and production boundaries
- `server_tokens off` reduces casual version disclosure but does not replace patching, module minimization, TLS, application security, or monitoring.
- Package installation may auto-start nginx before hardening; keep upstream/host firewall state understood and inspect the listener immediately.
- The worker account must not own configuration or deployable source. Keep the health page root-owned/read-only and define a separate controlled deployment identity later.
- A default server that serves content can leak the first tenant for unknown Host headers. Replace it with a rejecting default before multi-site production.
- Do not serve secrets, VCS directories, backups, environment files, private keys, or writable upload directories from the document root.
- The 10 MiB body limit is a product/security decision. Lower or route-specific limits are preferable when uploads are not required.
Stop before continuing if
- Stop if TCP/80 belongs to another process, prior web/config state cannot be archived, or console recovery is unavailable.
- Do not install/reload after repository trust, package, config parser, include-map, listener, or service errors.
- Do not open the firewall until public/private intent, IPv4/IPv6 listeners, upstream ACL, and port owner are known.
- Do not accept the baseline while sample/old content, unintended Server detail, failed units, unexpected includes, or journal errors remain.
- Do not deploy multiple sites until a rejecting default and deterministic Host isolation tests are in place.
verification
Inventory packages, listeners, and firewall
Confirm nginx or Apache is not already serving the port and capture the host baseline.
Why this step matters
A live inventory prevents the installation from colliding with an existing ingress, overwriting retained content, or opening a port without a known owner and recovery path.
What to understand
Capture OS/repository, addresses/routes, all IPv4/IPv6 listeners with owners, web packages, enabled sockets/services, firewall/upstream ACL, failed units, DNS, storage, and current HTTP response.
Inspect containers and socket activation as well as processes; a port can be acquired after a superficial empty `ss` filter.
Identify `/etc/nginx`, `/var/www`, `/srv/www`, TLS, logs, and configuration-management ownership before treating the host as fresh.
System changes
- No persistent change; reads host, network, package, firewall, service, filesystem, and web state.
Syntax explained
ss -lntp 'sport = :80'- Shows process-owned TCP/80 listeners.
dpkg-query -W- Lists matching installed package records.
ufw status verbose- Shows firewall status, defaults, and rules.
systemctl --failed- Records unrelated pre-existing service failures.
cat /etc/os-release && sudo ss -lntp 'sport = :80' && dpkg-query -W 'nginx*' 'apache2*' 2>/dev/null || true; sudo ufw status verbose; systemctl --failed --no-pagerPRETTY_NAME="Ubuntu 24.04 LTS" No listener on :80 Status: active
Checkpoint: Checkpoint: inventory
Continue whenUbuntu/repositories and intended addresses are known, TCP/80 ownership is explicit, prior web state is classified, and no unrelated failure remains.
Stop whenAn unknown listener, package origin, web root, firewall rule, failed service, management agent, or inaccessible console exists.
If this step fails
The port looks unused but nginx later reports address already in use.
Likely causeAn IPv6 listener, socket unit, container, race, or namespace was omitted.
sudo ss -lntp '( sport = :80 )'systemctl list-sockets --alldocker ps --format '{{.Names}} {{.Ports}}'
ResolutionFind the lifecycle owner and resolve ingress ownership deliberately before nginx starts.
Security notes
- Inventory contains internal topology and software versions; protect the change record.
Alternatives
- Use authoritative CMDB/config-management evidence only when reconciled with live socket and filesystem state.
Stop conditions
- An unknown listener, package origin, web root, firewall rule, failed service, management agent, or inaccessible console exists.
command
Archive any existing nginx state
Preserve /etc/nginx and the default web root when present before package changes.
Why this step matters
A root-only verified archive distinguishes a genuinely fresh install from an existing service and protects configuration/content selection before package maintainer scripts run.
What to understand
Create the backup directory 0700, archive both nginx config and default web root when present, list contents, and verify checksum.
Do not confuse this file archive with application-consistent database/uploads backup; dynamic systems need independent recovery.
Record explicitly when no prior state exists so rollback can stop/remove the fresh service without inventing files.
System changes
- Creates `/root/oneliners-backups` and, for existing state, a compressed archive plus checksum.
Syntax explained
install -d -m 0700- Creates a root-only backup directory.
if test -d /etc/nginx- Archives only when prior nginx state exists.
tar -C / -czf- Stores canonical relative configuration/content paths.
sha256sum- Records integrity for restore verification.
sudo install -d -m 0700 /root/oneliners-backups && if test -d /etc/nginx; then sudo tar -C / -czf /root/oneliners-backups/nginx-before-install.tgz etc/nginx var/www/html; fi && sudo sha256sum /root/oneliners-backups/nginx-before-install.tgz 2>/dev/null || echo 'Fresh install: no prior nginx archive'Fresh install: no prior nginx archive
Checkpoint: Checkpoint: backup
Continue whenExisting state yields a protected non-empty verifiable archive; a fresh host is explicitly documented.
Stop whenPrior state exists but archive/checksum/listing fails, disk is insufficient, or application data outside scope lacks backup.
If this step fails
tar reports a file changed while reading.
Likely causeA deployment or running service changes the web root/config during capture.
ps -ef | grep -E '[n]ginx|[r]sync|[t]ar'sudo lsof +D /var/www/html 2>/dev/null | head
ResolutionQuiesce the owning deployment or use an application-consistent snapshot and create a new archive.
Security notes
- Archives may contain credentials/private paths; keep them outside web roots and protect retention/transfer.
Alternatives
- Use a tested filesystem/provider snapshot when it consistently captures every required path.
Stop conditions
- Prior state exists but archive/checksum/listing fails, disk is insufficient, or application data outside scope lacks backup.
command
Install nginx from Ubuntu repositories
Use signed distribution packages and record both package and runtime versions.
Why this step matters
Ubuntu's signed package supplies a maintained binary, service unit, config layout, logrotate integration, worker identity, and security-update path.
What to understand
Inspect apt policy and transaction before accepting; a third-party candidate or unexpected removal is a blocker.
Record both `nginx -v` and dpkg version because Ubuntu backports fixes and the upstream-looking version alone is incomplete.
The package may start the default listener. Immediately recheck socket owner and firewall rather than assuming install is inert.
System changes
- Installs nginx packages/config/service/logrotate/web root and may start TCP/80.
Syntax explained
apt update- Downloads and verifies repository indexes.
apt install --yes nginx- Executes the reviewed distribution package transaction noninteractively.
nginx -v- Records runtime upstream-style version.
dpkg-query -W nginx- Records exact Ubuntu package revision.
sudo apt update && sudo apt install --yes nginx && nginx -v && dpkg-query -W nginxnginx version: nginx/1.24.x nginx 1.24.x-ubuntu
Checkpoint: Checkpoint: install
Continue whenApproved package installs cleanly and any new listener/service belongs only to nginx on intended addresses.
Stop whenRepository signature/origin, transaction, version, maintainer script, listener, or service result differs.
If this step fails
Package configuration fails because nginx cannot bind.
Likely causeAnother service acquired port 80 after inventory or was hidden in another address family.
sudo ss -lntp 'sport = :80'sudo journalctl -u nginx --since '-10 min' --no-pager
ResolutionKeep firewall controlled, resolve port ownership, then complete package configuration deliberately.
Security notes
- Installation can expose a default listener/sample page; verify edge and host firewall immediately.
Alternatives
- Stage the same signed package/config in an immutable image with parser and probe gates.
Stop conditions
- Repository signature/origin, transaction, version, maintainer script, listener, or service result differs.
config
Create a minimal baseline fragment
Hide the exact nginx version, set a bounded client body size, and retain sendfile behavior in a separate file.
Why this step matters
A small separate global fragment makes disclosure/body policy explicit without mixing tenant routing, TLS, or application behavior into nginx.conf.
What to understand
`server_tokens off` removes exact version from normal generated responses but cannot prevent fingerprinting.
A 10 MiB body ceiling is a commissioning default; applications that do not upload should use a lower site/location limit.
Inspect all includes for existing values to avoid duplicate directives at the same context, then keep root ownership/mode.
System changes
- Creates `/etc/nginx/conf.d/10-oneliners-baseline.conf` with global HTTP-context policy.
Syntax explained
server_tokens off- Suppresses nginx version in generated Server header/error pages.
client_max_body_size 10m- Rejects request bodies over 10 MiB with 413 at this scope.
tee conf.d- Writes an automatically included root-owned fragment.
/etc/nginx/conf.d/10-oneliners-baseline.confprintf 'server_tokens off;\nclient_max_body_size 10m;\n' | sudo tee /etc/nginx/conf.d/10-oneliners-baseline.confserver_tokens off; client_max_body_size 10m;
Checkpoint: Checkpoint: baseline
Continue whenThe fragment is root-owned, included once, nonconflicting, and its body limit matches commissioning requirements.
Stop whenAnother include defines conflicting values, required uploads exceed the limit, or config is generated elsewhere.
If this step fails
nginx -t reports duplicate client_max_body_size.
Likely causeThe directive appears twice in the same context or an include was copied into another file.
sudo grep -R -n 'client_max_body_size\|server_tokens' /etc/nginxsudo nginx -T
ResolutionChoose one authoritative scope/file and remove only the duplicate after backup.
Security notes
- Version suppression is cosmetic; patch and minimize the service regardless.
Alternatives
- Set stricter limits inside each server/location when tenant needs differ.
Stop conditions
- Another include defines conflicting values, required uploads exceed the limit, or config is generated elsewhere.
config
Replace the sample landing page
Publish a deterministic root-owned health page and keep deployment write access separate from the nginx worker account.
Why this step matters
Replacing the first-priority index file eliminates sample/retained content and creates deterministic evidence without giving the nginx worker deployment ownership.
What to understand
The packaged index order can choose `index.html` before `index.nginx-debian.html`; writing index.html avoids a false test caused by another file.
Use fixed non-secret content, root ownership, and 0644. This is a commissioning marker, not a deep application health endpoint.
List the root and expanded `root/index` directives before replacement so an existing production site is not overwritten.
System changes
- Replaces `/var/www/html/index.html` with a root-owned static readiness marker.
Syntax explained
tee index.html- Writes deterministic content to the first-priority packaged index path.
chown root:root- Prevents nginx/deployment identities from silently editing the marker.
chmod 0644- Allows worker read access without public filesystem write.
/var/www/html/index.htmlprintf '<!doctype html><title>nginx ready</title><h1>nginx ready</h1>\n' | sudo tee /var/www/html/index.html >/dev/null && sudo chown root:root /var/www/html/index.html && sudo chmod 0644 /var/www/html/index.html-rw-r--r-- 1 root root /var/www/html/index.html
Checkpoint: Checkpoint: health page
Continue whenThe intended default root's first-priority index contains only the marker and is root:root 0644.
Stop whenThe path contains production content, another root/index is active, ownership differs, or backup is unverified.
If this step fails
curl still returns the distribution welcome page.
Likely causeAnother server block/root/index file or process handles the request.
sudo nginx -T 2>&1 | grep -nE 'listen|server_name|root |index 'sudo ss -lntp 'sport = :80'ls -la /var/www/html
ResolutionIdentify the actual active server/root/index and replace only its intended commissioning file.
Security notes
- Never put system/package/debug data or secrets in a public health marker.
Alternatives
- Create a dedicated named commissioning server/root when replacing the packaged default is not appropriate.
Stop conditions
- The path contains production content, another root/index is active, ownership differs, or backup is unverified.
verification
Validate included configuration
Run nginx -t and dump the expanded config path list so an accidentally included stale file is visible before reload.
Why this step matters
nginx must parse every include and expose the expanded listener/server-name map before firewall or reload changes make configuration public.
What to understand
`nginx -t` validates syntax and file/key access; `nginx -T` prints the expanded config and can expose secrets, so protect captured output.
Review all listen/default_server/server_name/root directives, includes, duplicate names, and IPv4/IPv6 scope.
Parser success does not prove Host isolation or content; runtime named tests remain necessary after activation.
System changes
- No persistent change; parses and prints the complete enabled configuration.
Syntax explained
nginx -t- Validates syntax and referenced file access.
nginx -T- Validates then prints the complete expanded configuration.
grep … | head- Bounds a focused human review; retain full protected output when needed.
sudo nginx -t && sudo nginx -T 2>&1 | grep -E '^# configuration file|listen|server_name' | head -n 30nginx: configuration file /etc/nginx/nginx.conf test is successful listen 80 default_server;
Checkpoint: Checkpoint: validate
Continue whenParser succeeds and every listener/default/name/root/include belongs to the reviewed baseline.
Stop whenAny warning/error, unexpected include/listener/default/name, duplicate server, inaccessible file, or secret exposure occurs.
If this step fails
nginx -t passes but the dump shows an unknown enabled site.
Likely causeA stale symlink/conf.d file remains in the active include graph.
sudo find /etc/nginx/sites-enabled -maxdepth 1 -type l -lssudo nginx -T
ResolutionTrace ownership, archive, disable only the unintended include, and parse/dump again.
Security notes
- `nginx -T` may print key paths or credentials embedded in config; protect/redact its output.
Alternatives
- Run parser/diff gates in configuration management and compare the deployed expanded tree.
Stop conditions
- Any warning/error, unexpected include/listener/default/name, duplicate server, inaccessible file, or secret exposure occurs.
warning
Allow the packaged HTTP profile
Preview and add only nginx HTTP access, then verify the resulting numbered rule.
Why this step matters
Network exposure should follow validated listener ownership and use one known profile rather than broad unrelated ports.
What to understand
Inspect `/etc/ufw/applications.d/nginx` and dry-run generated IPv4/IPv6 rules before activation.
The Nginx HTTP profile normally allows public TCP/80; internal services need a source-limited rule instead.
Coordinate provider ACL/load balancer/NAT and keep console access; host UFW is only one boundary.
System changes
- Adds persistent UFW allow rules for the packaged Nginx HTTP profile.
Syntax explained
ufw --dry-run allow- Previews generated rule changes without applying them.
ufw allow 'Nginx HTTP'- Adds the named TCP/80 application profile.
ufw status numbered- Shows exact persistent rules and indices.
sudo ufw --dry-run allow 'Nginx HTTP' && sudo ufw allow 'Nginx HTTP' && sudo ufw status numbered[ 1] Nginx HTTP ALLOW IN Anywhere
Checkpoint: Checkpoint: firewall
Continue whenOnly intended TCP/80 IPv4/IPv6 rules are added and match upstream exposure design.
Stop whenProfile contains other ports, listener is unintended, management access is threatened, or upstream ACL is unknown.
If this step fails
Remote HTTP still times out after UFW allow.
Likely causeProvider firewall, routing, NAT, address family, DNS, or bind scope blocks the path.
sudo ss -lntp 'sport = :80'sudo ufw status numberedsudo tcpdump -ni any tcp port 80 -c 20
ResolutionTrace boundaries and correct the smallest intended rule rather than opening additional ports.
Security notes
- Do not expose private admin/status endpoints merely because they share nginx.
Alternatives
- Use an explicit source-CIDR rule or private load-balancer security group for nonpublic service.
Stop conditions
- Profile contains other ports, listener is unintended, management access is threatened, or upstream ACL is unknown.
verification
Enable and test nginx locally
Enable boot start, reload the validated configuration, request the health page, and inspect recent errors.
Why this step matters
Enable/reload after validation, then a deterministic client response plus service/journal/socket evidence proves the configured runtime—not merely files.
What to understand
Enable boot start and apply the validated config gracefully. Inspect new/old workers and exact listener addresses.
Request the actual health marker from loopback, capture headers, and verify version suppression without relying only on status.
Inspect journal and nginx error/access logs for startup, permission, duplicate name, and request errors; rehearse a controlled restart in a maintenance window.
System changes
- Enables nginx at boot, starts/reloads its processes, opens listeners/logs, and creates test records.
Syntax explained
systemctl enable --now- Persists boot activation and starts the service.
systemctl reload- Gracefully applies the already parsed configuration.
curl --fail --silent- Requires HTTP success and prints only marker body.
journalctl -u nginx -n 20- Reads bounded recent service events.
sudo systemctl enable --now nginx && sudo systemctl reload nginx && curl --fail --silent http://127.0.0.1/ && sudo journalctl -u nginx -n 20 --no-pager<h1>nginx ready</h1> Started nginx.service.
Checkpoint: Checkpoint: activate
Continue whenService is enabled/active, intended nginx owns TCP/80, marker is exact, Server lacks patch version, and logs show no unexplained errors.
Stop whenService/listener/content/header/journal/log differs, old workers persist unexpectedly, or another tenant responds.
If this step fails
systemd is active but curl receives connection refused.
Likely causenginx listens on another address/family, worker exited after master start, or firewall/network namespace differs.
sudo ss -lntp 'sport = :80'systemctl status nginx --no-pagersudo journalctl -u nginx -n 100 --no-pager
ResolutionCorrect listener/runtime failure, parse, reload/restart deliberately, and repeat local plus external probes.
Security notes
- A successful marker is not production hardening; add rejecting default, explicit sites, TLS, logs, and monitoring next.
Alternatives
- Start on a private/canary address and cut traffic only after external validation.
Stop conditions
- Service/listener/content/header/journal/log differs, old workers persist unexpectedly, or another tenant responds.
Finish line
Verification checklist
sudo nginx -t && systemctl is-enabled nginx && systemctl is-active nginxConfiguration succeeds; service is enabled and active.curl --fail --head http://127.0.0.1/Returns 200 and the Server header does not disclose the patch version.Recovery guidance
Common problems and safe checks
The package installs but nginx cannot bind port 80.
Likely causeApache, an older nginx, a container publisher, or a socket unit already owns the address.
sudo ss -lntp 'sport = :80'systemctl list-sockets --alldocker ps --format '{{.Names}} {{.Ports}}'
ResolutionIdentify and migrate/stop the intended owner under its rollback plan; never kill an unknown listener.
`nginx -t` reports a duplicate directive or default server.
Likely causeA stale conf.d/site include overlaps the packaged/default or baseline file.
sudo nginx -Tsudo find /etc/nginx -type l -lssudo grep -R -n 'default_server\|server_tokens\|client_max_body_size' /etc/nginx
ResolutionRemove/disable only the redundant authoritative include after backup, then parse again.
Local curl returns the old Apache or Ubuntu page.
Likely causeAnother process/vhost serves the request, index priority selects another file, or a proxy/cache is in the path.
sudo ss -lntp 'sport = :80'sudo nginx -T | grep -nE 'root |index |listen|server_name'ls -la /var/www/html
ResolutionFind the actual listener/server/root/index order and replace only the intended first-priority health file.
Local HTTP works but remote clients time out.
Likely causeUFW/provider ACL, routing, NAT, address family, or bind scope blocks the path.
sudo ss -lntp 'sport = :80'sudo ufw status numberedsudo tcpdump -ni any tcp port 80 -c 20
ResolutionTrace each boundary and correct the smallest intended rule; do not open unrelated ports.
nginx is active but reload failed and old workers remain.
Likely causeThe service preserved the last good config after a parser/reload error.
sudo nginx -tsystemctl status nginx --no-pagersudo journalctl -u nginx -n 100 --no-pagerps -ef | grep '[n]ginx'
ResolutionFix parser/runtime error, keep last-good workers serving, then reload and verify new worker start times.
Reference
Frequently asked questions
Why write index.html instead of index.nginx-debian.html?
The packaged index order can prefer index.html. Replacing the first-priority file makes the commissioning result deterministic.
Should TCP/80 stay public after HTTPS?
Often yes for same-host redirects and HTTP-01 renewal, but it should serve no sensitive application content.
Does nginx -t prove routing is correct?
No. It proves syntax/file access. Use `nginx -T` and named requests to prove listener and Host selection.
Recovery
Rollback
Stop the new service, remove its firewall rule, and restore the archived nginx tree if one existed.
- Delete only the UFW rule added for Nginx HTTP.
- Disable and stop nginx before restoring /root/oneliners-backups/nginx-before-install.tgz.
- Run nginx -t before starting any restored service.
- Purge packages only for a confirmed fresh install and retain application web roots.
Evidence