Your First Cloud Server in 30 Minutes: Create, Deploy, Enable HTTPS

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.

2026-09-26

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.

TimeActionDone when
0–3 minPick region and minimum spec2 vCPU / 2 GB, Ubuntu 24.04, 40 GB disk
3–7 minCreate instance, attach SSH key and security groupYou have a public IP and a key pair
7–10 minFirst SSH loginA prompt on the cloud host
10–14 minufw, updates, timezoneOnly 22 / 80 / 443 are open
14–19 minInstall Docker and the Compose pluginhello-world prints its banner
19–23 minDeploy an Nginx site with Composecurl 127.0.0.1 returns 200
23–26 minAdd an A record for your domaindig +short returns the server IP
26–30 minCaddy issues the HTTPS certificateThe browser shows a padlock
  1. Choose region and minimum spec, decide about auto-renewal
  2. Create the instance: Ubuntu 24.04 LTS, generate an ed25519 key pair
  3. Open exactly three security group rules: 22 (your IP only), 80, 443
  4. Log in over SSH and confirm the user and key permissions
  5. Harden minimally: ufw, unattended upgrades, clock sync
  6. Install Docker and Compose from the official APT repository
  7. Write a compose.yaml and bring up an Nginx site
  8. 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 apt finds 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.

  1. 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
  2. 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
  3. Credentials: generate an SSH key pair in the console (ed25519), or paste in your own public key. If the docs mention an ubuntu user, it is a standard cloud image
  4. Network: attach an elastic public IP, then add exactly three security group rules — port 22 from your own public IP (not 0.0.0.0/0), ports 80 and 443 from 0.0.0.0/0
  5. 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 recordTypeValueCovers
@A203.0.113.10apex example.com
wwwCNAME@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.

#CheckCommandExpected
1Service alive locallycurl -I http://127.0.0.1200 OK
2Containers runningdocker compose psapp and caddy both Up
3Ports listeningss -tlnp | grep -E ':(80|443)'docker-proxy on both ports
4Domain resolvesdig +short example.comYour public IP
5HTTPS servescurl -I https://example.com200 OK
6HTTP redirectscurl -I http://example.com301 with https Location
7Cert is in dateecho | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -datesnotBefore past, notAfter future
8Firewall allows itsudo ufw status verbose22 / 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

  1. 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
  2. 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
  3. Log rotation: docker compose logs does not rotate. Set log-opts.max-size and max-file in daemon.json for anything long-running, or a single log file will fill the 40 GB disk
  4. No credentials in Git: once a private key or .env is 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.

Want to price this for your own usage?

Tell us your region and spec — an engineer will size it and quote. We accept USDT TRC-20.

Top up now