The same website on a VPS or a dedicated server

Choose the plan

The site from lesson 4 needs very little: a few PHP files, one database, a mailbox elsewhere. Two virtual cores, 2 GB of memory and 40 GB of disk are enough for it and for the experiments that follow. Below that, a PHP build and a database import can run out of memory at the worst moment.

Look for four things in the offer. Backups, or at least snapshots you can restore in a minute. An IPv4 address and an IPv6 address. A console or rescue mode in the panel, for the day SSH stops answering. And a data centre near your visitors. VPS hosting shows how to create the machine at one provider, including the region, the image and the SSH key; the steps below start when that machine is running and you have its IP address.

An SSH key and the first login

A password over SSH can be guessed; a key cannot. If you created the server with a public key, as the VPS page describes, the first login needs no password. If the provider mailed you a root password, log in with it once and switch to keys immediately.

ssh root@203.0.113.10

The authenticity of host '203.0.113.10 (203.0.113.10)' can't be established. ED25519 key fingerprint is SHA256:.... This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])?

Type yes only if the fingerprint matches the one the provider shows in its panel. That comparison is the only defence against a machine that pretends to be your new server. The prompt changes to root@example:~#.

A sudo user and updates

Working as root all the time is how a typo becomes an outage. Make a normal user, give it the right to use sudo, and copy the key so that you can log in as that user:

adduser example

usermod -aG sudo example

cp -r /root/.ssh /home/example/.ssh

chown -R example:example /home/example/.ssh

chmod 700 /home/example/.ssh

chmod 600 /home/example/.ssh/authorized_keys

Open a second terminal and log in as the new user before you close the first one. If the key was copied wrongly, root still has a working session and you can fix it:

ssh example@203.0.113.10

From now on every command that changes the system starts with sudo. Then bring the system up to date:

sudo apt update

sudo apt upgrade -y

apt update refreshes the list of available packages, apt upgrade installs the newer versions. On a fresh server the second command may also upgrade the kernel; a reboot at the end is normal.

The firewall

On Ubuntu the tool is ufw, a front end to iptables, which on Ubuntu 24.04 runs on the nftables backend. Allow SSH first, then the web ports, and only then switch the firewall on. In the other order you lock yourself out of your own server:

sudo ufw allow OpenSSH

sudo ufw allow 80/tcp

sudo ufw allow 443/tcp

sudo ufw enable

Command may disrupt existing ssh connections. Proceed with operation (y|n)? y Firewall is active and enabled on system startup

sudo ufw status verbose

Status: active Logging: on (low) Default: deny (incoming), allow (outgoing), disabled (routed) New profiles: skip To Action From -- ------ ---- OpenSSH ALLOW IN Anywhere 80/tcp ALLOW IN Anywhere 443/tcp ALLOW IN Anywhere OpenSSH (v6) ALLOW IN Anywhere (v6) 80/tcp (v6) ALLOW IN Anywhere (v6) 443/tcp (v6) ALLOW IN Anywhere (v6)

Two rules open the web ports for the server that is installed next. The default policy rejects every incoming connection that no rule allows, which is what you want: the database and the panel stay invisible from the internet even if they listen. The rules are the same in any Linux distribution; Firewall Linux covers them in more detail.

Nginx, PHP-FPM and MariaDB

Four packages make the site run: the web server, the PHP processor, the database and a handful of PHP extensions that most PHP sites need. Ubuntu 24.04 ships PHP 8.3, which is the version this course uses:

sudo apt install nginx mariadb-server php8.3-fpm php8.3-mysql php8.3-curl php8.3-xml php8.3-mbstring php8.3-zip php8.3-gd unzip

The installer starts Nginx and MariaDB. PHP-FPM runs as a service of its own; Nginx uses it once a server block passes .php requests to its socket, which the block below does. Check with systemctl that all three services are running:

systemctl is-active nginx mariadb php8.3-fpm

active active active

Open the address of the server in a browser: the Nginx welcome page answers, which proves the firewall and the web server agree. If you see the browser error instead, check ufw status and systemctl status nginx. The full install, including the details of the default site, is in Install Nginx on Ubuntu.

MariaDB needs one command to be safe on a fresh install. It sets a root password for the database, removes anonymous users and test databases, and asks a few questions; answer yes to all of them:

sudo mariadb-secure-installation

The database now answers only on localhost, which is the default and the correct setting for a site that runs on the same machine. Do not open port 3306 in the firewall, ever.

The server block

A folder for the files, a configuration file for Nginx, a symbolic link that switches it on. The folder belongs to the site user, and its group is the group of the web server. Until lesson 8 gives the site a pool of its own, PHP runs as www-data, so the uploads folder will need group write access; the commands after the copy add it:

sudo mkdir -p /var/www/example.com/html

sudo chown -R example:www-data /var/www/example.com

sudo vi /etc/nginx/sites-available/example.com

server { listen 80; listen [::]:80; server_name example.com www.example.com; root /var/www/example.com/html; index index.php index.html; location / { try_files $uri $uri/ =404; } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm.sock; } location ~ /\.ht { deny all; } }

The location / block tries the requested file, then the folder, then answers 404. This is the right rule for the small site of this course. A front controller, which a framework or a CMS uses, sends every request to one file instead, with try_files $uri $uri/ /index.php?$query_string;. The PHP block sends every .php request to the default PHP-FPM socket; a private pool per site replaces it in lesson 8. The last block refuses any request for a hidden .htaccess file.

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/

sudo nginx -t

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful

sudo systemctl reload nginx

The domain does not have to point here yet. Nginx chooses a site by the Host header, so curl can pretend to be the domain and the request reaches your new block. The folder is still empty, so a request would answer 403; write a test page as the site user first:

sudo -u example sh -c 'echo "<h1>example.com</h1>" > /var/www/example.com/html/index.html'

curl -H "Host: example.com" http://203.0.113.10/

<h1>example.com</h1>

The rsync step below replaces this test file with the real site.

Copy the site with rsync

From your computer, in the folder with the site files, copy everything to the server. -a keeps permissions and times, -v lists the files, -z compresses the transfer, and --delete removes files on the server that no longer exist locally:

rsync -avz --delete ~/site/ example@203.0.113.10:/var/www/example.com/

sending incremental file list ./ config.php html/ html/index.php html/uploads/ ...

The destination is the site folder, not html/: config.php lands one level above the web root, exactly as it sat on shared hosting.

Be careful with --delete the first time: it makes the server match your folder exactly. If your uploads folder exists only on the server, it will be removed. Copy once without the option, compare, then add it for the regular updates.

PHP runs as www-data until lesson 8 creates a pool for the site, and the files rsync copied belong to example with the group example, so the site cannot write its uploads yet. Create the folder if the copy did not bring it, then give the web server group write access on that folder only:

sudo -u example mkdir -p /var/www/example.com/html/uploads

sudo chgrp -R www-data /var/www/example.com/html/uploads

sudo chmod -R g+w /var/www/example.com/html/uploads

Lesson 8 replaces this with a PHP-FPM pool that runs as example, and then the folder can go back to the group of the site.

Import the database

The files are only half the site; the table pages and its rows live in the database. Export it on the old host, move the dump, and import it on the new server. On the old host:

mysqldump -u example_user -p example_db > site.sql

scp site.sql example@203.0.113.10:~/

On the new server, create the database and a user, grant the access, and load the file. The three commands create the same names the old host used, so that config.php keeps working:

sudo mariadb -e "CREATE DATABASE example_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"

sudo mariadb -e "CREATE USER 'example_user'@'localhost' IDENTIFIED BY 'a-long-random-password';"

sudo mariadb -e "GRANT ALL ON example_db.* TO 'example_user'@'localhost';"

sudo mariadb example_db < ~/site.sql

A dump of a large site takes a while and prints nothing when it succeeds. Check the result inside the database: the table list should contain pages:

sudo mariadb -e "SHOW TABLES FROM example_db;"

+---------------------+ | Tables_in_example_db | +---------------------+ | pages | +---------------------+

If the old host used a different database user or password, edit config.php on the new server to match what you just created. The site then loads over the temporary IP with the Host header of the test above, before any DNS change.

If your site is WordPress, the side story lists the extra steps: Side story: WordPress (optional).

Lower the TTL and switch DNS

Do this in two steps, a day apart, and the move takes minutes instead of hours. First, a day before the switch, lower the TTL of the A and AAAA records to 300 seconds. The old answer then expires quickly when you change it:

example.com. 300 IN A 198.51.100.20

Second, when the new site answers correctly in the Host header test, change the A record to the new server, 203.0.113.10, and wait for the old TTL to pass. Watch the answer from a public resolver, which is closer to what visitors see than your own:

dig +short @1.1.1.1 example.com

203.0.113.10

Keep the old server running and its files unchanged for at least a day after the switch. Some visitors, and some mail servers, will still use the old address until their caches expire. When everything looks right, raise the TTL back to 3600 and cancel the old plan.

Certbot and renewal

A certificate from Let's Encrypt is free and takes two commands. Certbot reads the Nginx configuration, proves control of each name over HTTP, installs the certificate and, with --redirect, writes the http to https redirect. The first run also asks for an email address for expiry notices and for agreement with the terms of service:

sudo apt install certbot python3-certbot-nginx

sudo certbot --nginx --redirect -d example.com -d www.example.com

Successfully received certificate. Certificate is saved at: /etc/letsencrypt/live/example.com/fullchain.pem Key is saved at: /etc/letsencrypt/live/example.com/privkey.pem This certificate expires on 2027-02-04. Deploying certificate Successfully deployed certificate for example.com to /etc/nginx/sites-enabled/example.com Congratulations! You have successfully enabled HTTPS on https://example.com

Check that the redirect is there:

curl -I http://example.com/

HTTP/1.1 301 Moved Permanently Location: https://example.com/

Certbot also installs a systemd timer that renews the certificate twice a day when it is close to expiry. Check that the timer exists and that a renewal would succeed, without waiting for the real date:

systemctl list-timers | grep certbot

Fri 2026-11-06 15:22:00 UTC 9h left certbot.timer certbot.service

sudo certbot renew --dry-run

Congratulations, all simulated renewals succeeded: /etc/letsencrypt/live/example.com/fullchain.pem (success)

The dry run is the check to repeat after any change to the configuration. The certificate itself is explained in HTTPS and certificates in plain words, and the service that issues it in letsencrypt.org.

Logs

When the site misbehaves, three places answer most questions. Nginx writes one line per request to its access log and the reasons for failures to its error log:

sudo tail -n 5 /var/log/nginx/access.log

203.0.113.55 - - [06/Nov/2026:10:00:00 +0000] "GET / HTTP/1.1" 200 5123 "-" "Mozilla/5.0" 203.0.113.55 - - [06/Nov/2026:10:00:01 +0000] "GET /wp-login.php HTTP/1.1" 404 162 "-" "Mozilla/5.0"

sudo tail -n 5 /var/log/nginx/error.log

2026/11/06 09:59:12 [error] 1234#1234: *56 FastCGI sent in stderr: "PHP message: PHP Fatal error: Allowed memory size exhausted" while reading response header from upstream

The third place is the journal, which keeps the output of the services themselves. A PHP-FPM crash or a restart of Nginx appears here:

sudo journalctl -u php8.3-fpm -n 20 --no-pager

Read logs from the bottom up, and note the time of the first error. The first line is the cause; the hundred lines after it are consequences.

What changes on a dedicated server

Everything above works the same on a dedicated server: it is also an Ubuntu machine where you are root. The differences are physical.

For one small site a VPS is almost always the better buy: the same commands, a fraction of the price, and snapshots that restore in a minute. A dedicated server starts to make sense when you need many cores, a large disk array, or a network that no neighbour can affect.

Quiz

1 Why create a normal user with sudo instead of working as root?

  1. Root cannot run the web server
  2. It makes the server faster
  3. A mistyped command is far less destructive, and the log shows who did what
  4. SSH refuses root logins by default
Answer

c) Root can delete anything with one command. A normal user needs sudo for each change, which forces a moment of thought and leaves an audit trail.

2 In what order do you set up the firewall on a new server?

  1. Enable it first, then allow SSH
  2. Allow SSH and the web ports, then enable it
  3. Enable it and never allow anything
  4. Enable it after the site is online
Answer

b) With the default deny policy and no SSH rule, you lock yourself out. Allow the ports you need, then switch the firewall on.

3 Which directive makes Nginx send .php requests to PHP-FPM?

  1. root
  2. try_files
  3. server_name
  4. fastcgi_pass
Answer

d) fastcgi_pass names the socket or address of the PHP-FPM pool. root only says where the files are.

4 Why lower the TTL a day before moving the site to another server?

  1. It makes the new server faster
  2. Old cached answers expire quickly, so visitors reach the new address sooner
  3. It encrypts the DNS queries
  4. It reduces the certificate size
Answer

b) The TTL decides how long resolvers keep the old address. Lower it before the change, not after, so the change itself propagates in minutes.

5 The files are on the new server and the database is imported, but the site shows the old content. What is the most likely cause?

  1. The firewall blocks port 443
  2. The domain still resolves to the old server, or a cache still holds the old address
  3. PHP is not installed
  4. The database has no tables
Answer

b) The new machine cannot be reached until the DNS points at it and the old answers expire. Test with a Host header against the new IP to separate the two problems.

6 How do you know that certificate renewal will keep working?

  1. The browser shows a padlock today
  2. The certificate file exists
  3. certbot renew --dry-run succeeds and the timer is listed
  4. The domain is registered
Answer

c) The dry run repeats the real renewal without changing anything. A padlock today says nothing about what happens in 60 days.

7 Which task appears only on a dedicated server, not on a VPS?

  1. Installing Nginx
  2. Watching a RAID array and replacing a failed disk through the provider
  3. Creating a sudo user
  4. Issuing a certificate
Answer

b) The VPS provider hides the hardware. On a dedicated machine the disks, the array and the remote console become part of your job.

Article author: Arthur Isaev