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.
- Hardware is yours alone. The disks, the memory and the network port are not shared, so a noisy neighbour cannot slow you down. You also pay for the whole machine even when it is idle.
- RAID is your job. Two or four disks in RAID 1 or RAID 10 survive one failed disk. Without RAID, a disk failure is a restore from backup. Check the array with cat /proc/mdstat and watch it in monitoring.
- The remote console is your only keyboard. IPMI, iDRAC or a similar interface lets you see the boot screen, enter the BIOS and fix a broken network configuration. Learn where it is before you need it.
- Disks are replaced by the provider, on a support ticket, and the array rebuilds while the server runs. Keep the serial numbers and the RAID layout in your notes.
- Power, cooling and the network belong to the data centre. Ask about the uplink speed, the traffic limit and what happens on a hardware failure: a replacement disk in four hours is a different offer from one in four days.
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?
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?
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?
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?
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?
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?
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?
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