Your first cloud server in 30 minutes: create an instance, deploy an app with Docker Compose, point a domain, and get free HTTPS from Caddy.
Most people do not get stuck on "cloud servers are hard" — they get stuck on not knowing which step comes first. You buy a machine and cannot reach it over SSH; you get in and do not know what to install; you install something and now the domain and HTTPS are a mystery. This walkthrough follows the real order of operations. Run your first cloud server in 30 minutes, from picking a region to seeing the padlock in a browser, with every command ready to paste.
Assumptions: a brand-new Ubuntu 24.04 LTS instance, a domain already pointed at it, and a laptop where you can run ssh. No domain yet? Still follow along — just skip step 8 and hit the IP and port directly.
The 30-minute roadmap
Look at the whole path before touching anything. There are only eight moves, ranging from "click some buttons" to "paste a config file". Nothing here needs a documentation archaeology dig.
| Time | Action | Done when |
|---|---|---|
| 0–3 min | Pick region and minimum spec | 2 vCPU / 2 GB, Ubuntu 24.04, 40 GB disk |
| 3–7 min | Create instance, attach SSH key and security group | You have a public IP and a key pair |
| 7–10 min | First SSH login | A prompt on the cloud host |
| 10–14 min | ufw, updates, timezone | Only 22 / 80 / 443 are open |
| 14–19 min | Install Docker and the Compose plugin | hello-world prints its banner |
| 19–23 min | Deploy an Nginx site with Compose | curl 127.0.0.1 returns 200 |
| 23–26 min | Add an A record for your domain | dig +short returns the server IP |
| 26–30 min | Caddy issues the HTTPS certificate | The browser shows a padlock |
- Choose region and minimum spec, decide about auto-renewal
- Create the instance: Ubuntu 24.04 LTS, generate an ed25519 key pair
- Open exactly three security group rules: 22 (your IP only), 80, 443
- Log in over SSH and confirm the user and key permissions
- Harden minimally: ufw, unattended upgrades, clock sync
- Install Docker and Compose from the official APT repository
- Write a
compose.yamland bring up an Nginx site - Resolve the domain, then let Caddy take 80/443 and get the cert
In this order every step has an unambiguous "done" signal, so you never have to backtrack to verify the previous one. Doing it out of order — say, HTTPS before DNS — burns most of your half hour waiting and debugging.
Step 1: region and size — 2 vCPU and 2 GB is enough
Your first cloud server does not need to be big. The goal right now is to get through the whole flow, not to serve traffic. More configuration means more places to fail. If you just want one machine for a blog or a test, a bundled shape like Lightsail — compute, storage and bandwidth in one price, no traffic metering — removes a whole class of decisions.
- OS: Ubuntu 24.04 LTS. Good docs, current package sources (it ships the official Docker repository), and
aptfinds everything - Size: 2 vCPU / 2 GB RAM. Running Nginx plus Caddy plus a light app, sustained CPU usually stays under 20%
- Disk: a 40 GB SSD is enough. The OS image takes 3–6 GB, the rest is room for logs and build artefacts
- Bandwidth: 1–5 Mbps fixed, or pay per traffic. A first site will not have real traffic — do not buy for the peak
- Region: whichever is closest to your users. Hong Kong, Singapore and Japan usually land at 15–40 ms for Chinese-language audiences
Training jobs, databases and large builds are a different sizing question — handle those separately. Start at 2 vCPU and 2 GB regardless, because most providers let you resize freely: scaling up takes seconds, scaling down refunds proportionally, so guessing wrong is cheap.
Do not skip the region decision. It determines latency, compliance and where your backups live, and it is the hardest one to undo — a move means a new IP, fresh DNS records and reissued certificates. When two regions differ by roughly 20% in price, the conversion lost to latency usually costs more than the saving.
Step 2: create the instance, keys and security group
Every console names this differently (Create Instance, Launch instance, new cloud host), but the fields are the same. Three of them are where beginners get stuck, and each one bites you later rather than now.
- Image: Ubuntu 24.04 LTS (x86_64). Skip third-party panel images such as preinstalled control panels, and skip marketplace images of unclear provenance — every command below assumes an official image
- Size: 2 vCPU / 2 GB with a 40 GB SSD / premium disk. Start on pay-as-you-go; convert to monthly once it is stable
- Credentials: generate an SSH key pair in the console (ed25519), or paste in your own public key. If the docs mention an
ubuntuuser, it is a standard cloud image - Network: attach an elastic public IP, then add exactly three security group rules — port
22from your own public IP (not 0.0.0.0/0), ports80and443from 0.0.0.0/0 - Wrap-up: give the instance a name you will recognise (
first-web-01) and check whether auto-renew is on — a throwaway test box should be released, not billed forever
A private key is private. Never paste one into an online tool, a screenshot or a chat window. Console-generated keys are usually shown in full exactly once, so tighten permissions right after downloading:
chmod 600 ~/Downloads/your-key.pem
Anything looser than 600 (say 644) and OpenSSH refuses outright with UNPROTECTED PRIVATE KEY FILE.
Note the public IP from the console. Everything below uses 203.0.113.10 as a placeholder — substitute your own IP, or the domain you are about to attach.
Step 3: your first SSH login
Run this on your own machine, not on the server:
ssh -i ~/Downloads/your-key.pem [email protected]
The first connection asks Are you sure you want to continue connecting (yes/no)? — that is the host fingerprint being recorded. Check the IP, then type yes. You should land on a prompt like ubuntu@first-web-01:~$.
If you get Permission denied (publickey), check three things in order: ① the user is ubuntu, not root (official images disable direct root login); ② -i points at the private key, without the .pub suffix; ③ the public key attached at creation really is the pair you are holding.
One more gotcha: the security group. If port 22 is restricted to your home IP and your egress IP changed (new WiFi, VPN, office network), you will simply time out. Find your own with curl ifconfig.me.
Spend 30 seconds confirming the environment before installing anything:
cat /etc/os-release | head -n 3
whoami
ip -br addr
curl -sI http://ifconfig.me
whoami should print ubuntu, ip -br addr shows the public NIC, and the last one confirms this machine's egress IP so you can set the port 22 source rule correctly.
Then add a block to ~/.ssh/config so you never retype a long command:
Host web
HostName 203.0.113.10
User ubuntu
IdentityFile ~/Downloads/your-key.pem
Verify with ssh web. This stanza is the only thing you copy when you later add a deploy user or a second machine.Treat ubuntu as your only way in. Prefix anything needing root with sudo; the privilege boundary stays obvious and auditing stays clean. If you later add a separate deploy user, just copy this entry's public key across.
Step 4: minimal hardening with ufw and updates
The cloud security group is the first wall (traffic dies before it reaches the host); ufw inside the host is the second. Keeping both costs essentially nothing. Order matters: allow 22 first, then enable the default-deny policy. This step is deliberately minimal; the other eight items to add before you serve real traffic are in the cloud server security baseline.
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw allow 80,443/tcp
sudo ufw enable
sudo ufw status verbose
sudo ufw allow 22/tcp must come before sudo ufw enable. Reverse the order and your current SSH session dies immediately. If it does, do not panic: most providers ship a VNC or serial rescue console — open it, run sudo ufw disable, fix the security group, then re-enable.
Equally important: do not close port 22 in the cloud security group just because ufw is on. Both walls must be open for you to get in.
Next, knock out patches, timezone and automatic security updates in one go.
sudo apt-get update && sudo apt-get -y upgrade
sudo timedatectl set-timezone Asia/Shanghai
timedatectl
The last command prints local time and NTP sync status. A wrong clock corrupts log timelines, certificate validity checks and every cron job — worth the 30 seconds.
A fresh instance is typically missing dozens of security patches, so turn on unattended upgrades:
sudo apt-get install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades
Adding yourself to the docker group saves thousands of sudo keystrokes — know the price first. After sudo usermod -aG docker $USER, docker runs without sudo, and the ability to control Docker is the ability to do anything as root. Perfectly fine on a personal box; on a shared machine keep writing sudo docker.
Step 5: install Docker from the official repository
For anything long-lived, use the official APT repository: newer versions, and upgrades that apt can actually manage. The get.docker.com convenience script is fine for a throwaway lab, but its installs sit outside package management and get awkward later.
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
The fourth line imports the GPG key and the next three add the repository source. $(. /etc/os-release && echo "$VERSION_CODENAME") resolves the current codename automatically (24.04 is noble), so this exact block keeps working across release upgrades.Start it and verify — this is the first real "done" marker in the whole chain:
sudo systemctl enable --now docker
systemctl is-active docker
docker compose version
sudo docker run --rm hello-world
systemctl is-active docker should print active, docker compose version should print Docker Compose version v2.x (v2 is what makes the space-separated docker compose form work), and the last command pulls an image under 30 MB and prints a banner. Hello from Docker! means the registry, runtime and networking are all fine.Seeing permission denied while trying to connect to the Docker daemon socket? Your user is not in the docker group yet. Run sudo usermod -aG docker $USER and then log out and back in over SSH (group membership is loaded at login). Confirm with groups | tr " " "\n" | grep docker.
Step 6: deploy your first app with Compose
Time to put an actual app on it. Compose pays off immediately: swapping applications later means editing one YAML file instead of remembering docker run flags and container names.
mkdir -p ~/site/nginx && cd ~/site
cat > nginx/index.html <<'EOF'
<!doctype html><meta charset=\"utf-8\"><title>it works</title>
<h1>My first cloud server is up</h1>
EOF
cat > nginx/default.conf <<'EOF'
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ =404; }
}
EOF
cat > compose.yaml <<'EOF'
services:
app:
image: nginx:1.27-alpine
restart: unless-stopped
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx:/usr/share/nginx/html:ro
ports:
- \"80:80\"
EOF
nginx:1.27-alpine is the official slim image, a few tens of MB. restart: unless-stopped starts it on boot and revives it if the container dies.
Bring it up and check
Bring it up and check:
cd ~/site && docker compose up -d
docker compose ps
curl -I http://127.0.0.1
docker compose ps should show app as Up, and curl -I should return HTTP/1.1 200 OK with a Server: nginx/1.27.x header. Follow logs with docker compose logs -f app; after editing config, docker compose restart app reloads it (edits to static files need no restart at all — the directory is mounted, so a browser refresh is enough).
If docker compose ps shows Exit (1), read the real reason with docker compose logs --tail 20 app — nine times out of ten it is a syntax error in default.conf.Get image: and build: straight first: pull a published image with image: nginx:1.27-alpine; with your own Dockerfile use build: ./app (and add --build the first time); set both and Compose builds, then tags the result. To ship your own app, swap image: for build: ./app and change nothing else.
At 200 OK your server is already a working website — nobody just knows its address yet. That is the next problem.
Step 7: point a domain at it
Add an A record for your domain pointing at the public IP. Most consoles put this under Website, DNS or Domain Resolution, and the form asks for three values: host record, record type and record value.
| Host record | Type | Value | Covers |
|---|---|---|---|
| @ | A | 203.0.113.10 | apex example.com |
| www | CNAME | @ | www.example.com follows the apex |
Save and wait for propagation. Globally this usually takes minutes; local resolvers may hold on until the original TTL expires (commonly 600 seconds). Do not keep editing settings during the wait — that resets the clock.
dig +short example.com
dig +short example.com @1.1.1.1
curl -I http://example.com
A bare IP from dig +short means resolution works, and a 200 from curl means traffic is really reaching your Nginx. dig @1.1.1.1 bypasses your local cache and asks a public resolver directly — invaluable when "it does not work for me but works for everyone else".
If you are behind a CDN, turn the proxy off first (grey cloud / DNS only). The HTTP-01 challenge in the next step needs requests to reach the origin directly. Turn the proxy back on after the certificate is issued.Step 8: HTTPS on autopilot with Caddy
What Caddy buys you is zero-maintenance TLS: it obtains, renews and swaps certificates on its own, ahead of expiry. Nginx plus certbot does the same job, but you have to remember to renew and reload. The default path here is Caddy, because fewer things to forget is the point.
Let Caddy take ports 80 and 443 and reverse-proxy to Nginx, which then becomes reachable only inside the Docker network. Overwrite ~/site/compose.yaml:
cat > ~/site/compose.yaml <<'EOF'
services:
app:
image: nginx:1.27-alpine
restart: unless-stopped
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx:/usr/share/nginx/html:ro
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
EOF
cat > ~/site/Caddyfile <<'EOF'
example.com, www.example.com {
reverse_proxy app:80
}
EOF
cd ~/site && docker compose up -d
Notice that ports: is gone from app — it no longer faces the internet and is reachable only over the Docker network. app:80 in the Caddyfile is a Compose service name; do not write 127.0.0.1:80, or Caddy will look inside its own container and return 502 forever. On startup Caddy reads this file, sees the domain names and immediately goes to request a certificate.
How the certificate is issued and renewed
Three things are worth understanding about this step: where the certificate is stored, what a successful issuance looks like in the logs, and the equivalent route if you would rather not use Caddy.
caddy_data holds the certificates. Do not skip it. Without that volume, every docker compose down forces a fresh request, and you will hit the Let's Encrypt rate limit (5 duplicate certificates per week for the same name set) and a screen full of too many certificates already issued for this exact set of domains. When you hit that wall the only real fix is waiting a week.
docker compose logs -f caddy | grep -iE 'certificate|error'
certificate obtained successfully means you are done — first issuance usually takes 5 to 20 seconds. HTTP is redirected to HTTPS with a 301 automatically; that is Caddy default behaviour, nothing to configure. The most common failure is the domain not resolving to this machine yet (dig returns nothing), or ports 80 blocked by the security group or ufw.
Renewal is handled by Caddy itself before expiry. No cron job needed.
Prefer Nginx and certbot? The equivalent path is:
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Pick option 1 when it asks which Nginx configuration to edit, and certbot adds the 443 listener and redirect for you. A systemd timer handles renewal, but dry-run it once first: sudo certbot renew --dry-run. That path makes you carry two extra concepts (the chain, and timed renewal), which is why Caddy is the default here.
The acceptance checklist
Do not conclude "it must be working" from vibes. Run each row. If one fails, go back to the matching section rather than moving on — plenty of "HTTPS is configured but the site is down" tickets are really row 8.
| # | Check | Command | Expected |
|---|---|---|---|
| 1 | Service alive locally | curl -I http://127.0.0.1 | 200 OK |
| 2 | Containers running | docker compose ps | app and caddy both Up |
| 3 | Ports listening | ss -tlnp | grep -E ':(80|443)' | docker-proxy on both ports |
| 4 | Domain resolves | dig +short example.com | Your public IP |
| 5 | HTTPS serves | curl -I https://example.com | 200 OK |
| 6 | HTTP redirects | curl -I http://example.com | 301 with https Location |
| 7 | Cert is in date | echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates | notBefore past, notAfter future |
| 8 | Firewall allows it | sudo ufw status verbose | 22 / 80 / 443 all ALLOW |
Finally open https://example.com in a real browser. Browsers are stricter than curl — they also validate the chain, SNI and mixed content. Once that passes, your first cloud server is officially live.
How to get it done in 5 minutes next time: commit compose.yaml, Caddyfile and nginx/ from ~/site to a Git repository. Rebuilding is then git clone plus docker compose up -d, with no config retyping. A reproducible deployment is itself the best backup.
After launch: backups, monitoring and common traps
- Backups: schedule daily disk snapshots with 7-day retention. Snapshots are a provider-side operation that costs no extra disk, but they are not a data backup — export databases separately and keep the copy elsewhere
- Monitoring: run something like Uptime Kuma, poking your domain from the outside every minute and alerting you on failure. Without an external probe, a server that dies at 2am is something you learn from a customer
- Log rotation:
docker compose logsdoes not rotate. Setlog-opts.max-sizeandmax-fileindaemon.jsonfor anything long-running, or a single log file will fill the 40 GB disk - No credentials in Git: once a private key or
.envis committed, treat it as compromised and rotate it — deleting the file does not unpublish it
The four traps beginners fall into: ① opening 22 in the security group but forgetting 80 and 443, so localhost works and the internet does not; ② pointing DNS at a private IP or the pre-migration address; ③ running ufw enable before allowing 22 and locking yourself out; ④ skipping the caddy_data volume and triggering certificate rate limits. The first three are findable in under two minutes; the fourth costs the most.
Stuck on any step? Do not retry blindly. Send our engineers the exact error text plus the output of docker compose ps and docker compose logs --tail 50, and tell us which step you reached. And if you have not bought yet, ask first: getting the first server wrong costs a lot more than half an hour of advice.