Your Next.js site runs on localhost:3000. Your Express API answers on localhost:4000. It all works, and nobody else in the world can see it. The gap between that moment and a real address like https://example.com feels huge the first time: servers, DNS, ports, certificates, pipelines. Every tutorial seems to skip the one step you are stuck on.
This guide closes that gap in one sitting. We take a small Next.js + Express app, put it in Docker, rent a Linux server on AWS EC2, point a GoDaddy domain at it, put Nginx in front, add a free Let's Encrypt certificate, and finish with GitHub Actions so every git push updates the live site by itself. This is the same setup that runs this website.
What we are building
The app is a tiny guestbook. The Next.js front end shows the notes and has a form to add one. The Express API stores the notes in a JSON file. Small enough to read in five minutes, but it has every part a real project has: a server-rendered page, a browser calling an API, environment variables and data that must survive a redeploy.
Here is the whole trip a visitor's request makes, from their browser to your code:
Browser: https://example.com | | 1. "Where is example.com?" vGoDaddy DNS -> answers 203.0.113.10 (your Elastic IP) | | 2. HTTPS request to 203.0.113.10:443 vAWS EC2 (Ubuntu) - security group allows 22, 80, 443 | vNginx (on the server) - holds the SSL certificate |-- example.com -> 127.0.0.1:3000 -> [web container: Next.js] '-- api.example.com -> 127.0.0.1:4000 -> [api container: Express] | volume: notes-data GitHub: git push -> Actions -> SSH into EC2 -> docker compose upLine by line
- DNS is the phone book of the internet. GoDaddy keeps the entry that turns your name into your server's IP address.
- An AWS firewall around the server. Only these ports can be reached from the internet.
- Nginx does the HTTPS work, then talks plain HTTP to the containers on the same machine.
- One server, two names. Nginx reads the requested name and picks the right container.
- A Docker volume keeps the notes file when the container is rebuilt.
- The last step: every push to main rebuilds and restarts the app automatically.
Keep this map in mind. Every section below builds one box of it, and when something breaks later you can walk the map from top to bottom to find where.
Before you start
- An AWS account. Sign up at aws.amazon.com. You need a card, even for the free plan.
- A domain on GoDaddy. Any name works; we use
example.comhere. - A GitHub account with your project in a repository.
- Docker Desktop on your computer, to test the containers before the server does.
- A terminal with
ssh(built into macOS, Linux and Windows 10+).
Step 1: The app, ready for Docker
The repository looks like this. Two folders, one for each service, and the deploy files at the top:
guestbook/|-- api/ Express API| |-- src/server.js| |-- package.json| '-- Dockerfile|-- web/ Next.js front end| |-- app/page.tsx| |-- app/note-form.tsx| |-- next.config.ts| '-- Dockerfile|-- nginx/guestbook.conf copied to the server later|-- docker-compose.yml path A: build on the server|-- docker-compose.prod.yml path B: pull ready images'-- .github/workflows/ automatic deploysThe Express API
A normal Express 5 server with three routes. Two details matter for production: it reads its settings from environment variables, and it trusts the proxy in front of it.
// api/src/server.jsimport { mkdir, readFile, writeFile } from 'node:fs/promises';import path from 'node:path';import cors from 'cors';import express from 'express'; const PORT = Number(process.env.PORT ?? 4000);const DATA_DIR = process.env.DATA_DIR ?? './data';const FILE = path.join(DATA_DIR, 'notes.json');const ORIGINS = (process.env.CORS_ORIGIN ?? 'http://localhost:3000').split(','); async function load() { try { return JSON.parse(await readFile(FILE, 'utf8')); } catch { return []; }} async function save(notes) { await mkdir(DATA_DIR, { recursive: true }); await writeFile(FILE, JSON.stringify(notes, null, 2));} const app = express();app.set('trust proxy', 1);app.use(cors({ origin: ORIGINS }));app.use(express.json({ limit: '10kb' })); app.get('/health', (req, res) => { res.json({ ok: true, uptime: Math.round(process.uptime()) });}); app.get('/api/notes', async (req, res) => { const notes = await load(); res.json(notes.slice(-50).reverse());}); app.post('/api/notes', async (req, res) => { const name = String(req.body?.name ?? '').trim().slice(0, 40); const message = String(req.body?.message ?? '').trim().slice(0, 280); if (!name || !message) { return res.status(400).json({ error: 'Name and message are required.' }); } const notes = await load(); const note = { id: Date.now(), name, message, at: new Date().toISOString() }; notes.push(note); await save(notes); res.status(201).json(note);}); app.listen(PORT, '0.0.0.0', () => { console.log(`API listening on port ${PORT}`);});Line by line
- Settings come from environment variables, with defaults for local development. Docker Compose sets the real values.
- Which websites may call this API from a browser (CORS). In production that is your https:// domain.
- Nginx sits in front of Express. This tells Express to read the visitor's real IP and protocol from the X-Forwarded-* headers Nginx adds.
- A tiny route that says “I am alive.” Docker uses it to check the container, and you can curl it when debugging.
- Express 5 catches errors thrown in async handlers by itself, so no try/catch wrapper is needed here.
- Listen on every network interface. Inside a container, 127.0.0.1 would only be reachable from the container itself.
# api/DockerfileFROM node:22-alpineWORKDIR /appENV NODE_ENV=production COPY package.json package-lock.json ./RUN npm ci --omit=dev COPY src ./srcRUN mkdir -p /app/data && chown -R node:node /app/dataUSER node EXPOSE 4000CMD ["node", "src/server.js"]Line by line
- Start from the official Node.js 22 image on Alpine Linux, about 60 MB.
- Copy only the package files first. Docker caches this layer, so
npm cireruns only when dependencies change. - Install exactly what the lockfile says, without dev dependencies.
- Create the data folder and give it to the non-root
nodeuser, so the app can write notes. - Run as a normal user, not root. If someone breaks in through the app, they get much less power.
The Next.js front end
One setting turns a Next.js app into something that fits neatly in a container: output: 'standalone'. The build then writes a folder with a tiny server.js and only the node_modules files the app really uses.
// web/next.config.tsimport type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'standalone',}; export default nextConfig;Line by line
- Build a self-contained server in .next/standalone. The final image does not need npm or the full node_modules.
The page runs on the server, so it can call the API by its internal address. The form runs in the visitor's browser, so it must use the public address. That difference is the most common source of “it works locally but not on the server” bugs, so look closely:
// web/app/page.tsximport NoteForm from './note-form'; export const dynamic = 'force-dynamic'; type Note = { id: number; name: string; message: string; at: string }; async function getNotes(): Promise<Note[]> { const res = await fetch(`${process.env.API_URL}/api/notes`, { cache: 'no-store' }); if (!res.ok) throw new Error(`API answered ${res.status}`); return res.json();} export default async function Home() { const notes = await getNotes(); return ( <main> <h1>Guestbook</h1> <p>Leave a note. It travels from your browser to Nginx, to Express, and back.</p> <NoteForm /> <ul> {notes.map((note) => ( <li key={note.id}> <strong>{note.name}</strong> {note.message} </li> ))} </ul> </main> );}Line by line
- Render on every request. Without this, Next.js would try to render the page at build time, when the API is not running.
- Read on the server at request time. In Docker this is http://api:4000, the API container's name on the internal network.
// web/app/note-form.tsx'use client'; import { useRouter } from 'next/navigation';import { useState } from 'react'; const API = process.env.NEXT_PUBLIC_API_URL; export default function NoteForm() { const router = useRouter(); const [error, setError] = useState(''); async function send(form: FormData) { setError(''); const res = await fetch(`${API}/api/notes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: form.get('name'), message: form.get('message') }), }); if (!res.ok) return setError((await res.json()).error ?? 'Something went wrong.'); router.refresh(); } return ( <form action={send}> <input name="name" placeholder="Your name" maxLength={40} required /> <input name="message" placeholder="Say hello" maxLength={280} required /> <button type="submit">Sign</button> {error && <p role="alert">{error}</p>} </form> );}Line by line
- This component runs in the browser.
- NEXT_PUBLIC_ variables are copied into the JavaScript at build time. The browser can't reach http://api:4000, so this must be the public https://api.example.com.
- Ask Next.js to render the server page again, so the new note appears.
# web/Dockerfile# ---- 1. Install dependencies ----FROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci # ---- 2. Build the app ----FROM node:22-alpine AS builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .ARG NEXT_PUBLIC_API_URLENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URLENV NEXT_TELEMETRY_DISABLED=1RUN npm run build # ---- 3. Run it, with only what it needs ----FROM node:22-alpine AS runnerWORKDIR /appENV NODE_ENV=productionENV PORT=3000ENV HOSTNAME=0.0.0.0COPY --from=builder /app/public ./publicCOPY --from=builder --chown=node:node /app/.next/standalone ./COPY --from=builder --chown=node:node /app/.next/static ./.next/staticUSER nodeEXPOSE 3000CMD ["node", "server.js"]Line by line
- A multi-stage build: three temporary images in one file. Only the last one is kept.
- Receive the public API address as a build argument and expose it to
next build. - The heavy step. It needs more than 1 GB of memory, which matters on a small server (see Step 3).
- The final image starts clean and copies only the build output: no source code, no dev tools.
- Make the standalone server listen on all interfaces, for the same reason as the API.
- The self-contained server from the standalone build.
- Standalone output does not include static assets, so copy them next to it.
- Start the app with plain Node.js. No
next startneeded.
Add a .dockerignore next to each Dockerfile so your local node_modules, .next and .env files never get copied into an image:
# web/.dockerignorenode_modules.next.env*npm-debug.logDocker Compose ties them together
Compose starts both containers with one command, puts them on a private network where they can call each other by name, and remembers all the settings:
# docker-compose.ymlservices: api: build: ./api environment: PORT: 4000 DATA_DIR: /app/data CORS_ORIGIN: https://example.com,https://www.example.com volumes: - notes-data:/app/data ports: - "127.0.0.1:4000:4000" healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:4000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 30s timeout: 5s retries: 3 restart: unless-stopped web: build: context: ./web args: NEXT_PUBLIC_API_URL: https://api.example.com environment: API_URL: http://api:4000 ports: - "127.0.0.1:3000:3000" depends_on: api: condition: service_healthy restart: unless-stopped volumes: notes-data:Line by line
- Only your real website may call the API from a browser.
- Store the notes in a named volume. Rebuilding or replacing the container keeps the data.
- Publish the port on the server's loopback only. Nginx can reach it; the internet cannot. This also stops Docker from punching a hole through the firewall.
- Docker calls /health every 30 seconds and marks the container healthy or unhealthy.
- The public address, passed into the web build.
- Server-to-server calls use the service name
api. Inside a container,localhostmeans that container itself. - Start the web container only after the API passes its health check.
- If the app crashes or the server reboots, Docker starts it again.
Before renting anything, prove it works on your own machine. For a local test, use http://localhost:4000 as the public URL (edit the two example.com lines temporarily, or run the apps with npm run dev):
docker compose up -d --builddocker compose pscurl http://localhost:4000/healthopen http://localhost:3000Line by line
- Build both images and start the containers in the background (-d).
- Both services should say “running”, and the API “(healthy)”.
Step 2: Launch an EC2 server
EC2 (Elastic Compute Cloud) rents you a virtual computer in an AWS data center. You get full control: your own Linux, your own ports, your own Docker. Open the AWS Console, pick a Region close to your visitors in the top-right menu (for example Singapore, ap-southeast-1), then go to EC2 → Instances → Launch instances.
- Name:
guestbook. - Application and OS image: Ubuntu Server 24.04 LTS, 64-bit (x86). LTS versions get security updates for five years.
- Instance type:
t3.small(2 GB memory) is comfortable.t3.micro(1 GB) works too, but only with swap (Step 3) or with path B, where the build happens on GitHub. - Key pair: create a new one, type ED25519, format
.pem. The browser downloadsguestbook.pemonce; AWS does not keep a copy, so store it safely. - Network settings → Edit: create a security group with three inbound rules: SSH (22) from My IP, HTTP (80) from anywhere, HTTPS (443) from anywhere.
- Storage: 20 GB gp3. Docker images fill the default 8 GB quickly.
- Click Launch instance, then wait until the state says Running.
Give it a fixed address: Elastic IP
A normal EC2 public IP changes every time the instance stops and starts. Your domain needs an address that never moves. Go to EC2 → Elastic IPs → Allocate Elastic IP address → Allocate, then select it and choose Actions → Associate Elastic IP address, and pick your guestbook instance. Write the address down; we use 203.0.113.10 as the example.
Step 3: Connect and prepare the server
On your computer, protect the key file (SSH refuses keys anyone can read), then log in as the default ubuntu user:
chmod 400 ~/Downloads/guestbook.pemmv ~/Downloads/guestbook.pem ~/.ssh/ssh -i ~/.ssh/guestbook.pem ubuntu@203.0.113.10Line by line
- Only you may read the key. Without this, ssh stops with “UNPROTECTED PRIVATE KEY FILE”.
- Type “yes” the first time to trust the server's fingerprint.
Typing that every time gets old. Add a shortcut to ~/.ssh/config on your computer, and from then on ssh guestbook is enough:
# ~/.ssh/configHost guestbook HostName 203.0.113.10 User ubuntu IdentityFile ~/.ssh/guestbook.pemNow, on the server, install updates and add a swap file. Swap is disk space the system can use as slow extra memory. Without it, a Next.js build on a 1–2 GB server is often killed halfway with no clear message.
# on the serversudo apt update && sudo apt upgrade -ysudo fallocate -l 2G /swapfilesudo chmod 600 /swapfilesudo mkswap /swapfilesudo swapon /swapfileecho '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstabfree -hLine by line
- Install security updates. If it asks to reboot later, run
sudo rebootand reconnect. - Reserve a 2 GB file on disk for swap.
- Only root may read it; swap can contain pieces of memory.
- Turn the swap on again after every reboot.
- Check: the Swap line should now show 2.0Gi.
Step 4: Install Docker on the server
Install Docker Engine from Docker's own repository, not Ubuntu's older docker.io package. This gives you the current version and the docker compose plugin. On the server:
# on the serversudo apt install -y ca-certificates curlsudo install -m 0755 -d /etc/apt/keyringssudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.ascsudo chmod a+r /etc/apt/keyrings/docker.ascecho "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.listsudo apt updatesudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginsudo usermod -aG docker $USERLine by line
- Download Docker's signing key, so apt can check the packages really come from Docker.
- Add Docker's repository for this Ubuntu version (noble for 24.04).
- Engine, CLI, Buildx and the Compose v2 plugin in one go.
- Let the ubuntu user run docker without sudo. It takes effect at the next login.
Log out (exit), log back in with ssh guestbook, and check that everything is there:
docker run --rm hello-worlddocker compose versionStep 5: Get the code onto the server
The server pulls your code from GitHub. If the repository is private, the server needs permission. A deploy key is an SSH key that can read just this one repository, which is safer than putting your personal key on a server. On the server:
# on the serverssh-keygen -t ed25519 -C "guestbook-server" -f ~/.ssh/id_ed25519 -N ""cat ~/.ssh/id_ed25519.pubLine by line
- Make a key pair with no passphrase, so automated deploys can use it.
- Copy the printed line that starts with ssh-ed25519.
On GitHub, open your repository → Settings → Deploy keys → Add deploy key, paste it, leave Allow write access unticked, and save. Then clone and start the app:
# on the servergit clone git@github.com:your-github-user/guestbook.git ~/guestbookcd ~/guestbookdocker compose up -d --buildcurl http://127.0.0.1:4000/healthcurl -I http://127.0.0.1:3000Line by line
- Use the SSH address (git@github.com:…), not https, so the deploy key is used.
- The first build takes a few minutes on a small server. Later builds reuse cached layers.
- Expect
HTTP/1.1 200 OK. The app runs, but only the server itself can reach it yet.
Step 6: Point your GoDaddy domain at the server
Now we teach the internet's phone book where your site lives. An A record says “this name → this IPv4 address.” We need three of them: the bare domain, www, and api.
- Sign in to GoDaddy, open My Products (your Domain Portfolio), choose your domain, then open DNS → DNS Records.
- Find the existing A record with Name
@(often pointing to “Parked”). Edit it: Value = your Elastic IP, TTL = 600 seconds (or 1/2 hour). Save. - GoDaddy usually has a CNAME record
www→@. Keep it;wwwwill follow the@record. If it is missing, add an A recordwww→ your Elastic IP. - Add a record: Type A, Name
api, Value = your Elastic IP, TTL 600. Save. - Delete any other A or AAAA records for
@,wwworapithat point somewhere else, such as an old host or a forwarding service. Two answers for one name make visitors land on the wrong server at random.
DNS changes usually spread in a few minutes, sometimes up to an hour or two. Check from your computer:
dig +short example.comdig +short www.example.comdig +short api.example.comLine by line
- Each line should print your Elastic IP. On Windows use
nslookup example.com.
Step 7: Nginx, the front door
Right now the app only listens on 127.0.0.1. Nginx is a fast web server that listens on the public ports 80 and 443 and forwards each request to the right container. This is called a reverse proxy. It also handles HTTPS, slow or broken clients and request size limits, so your Node.js processes don't have to. On the server:
# on the serversudo apt install -y nginxsudo cp ~/guestbook/nginx/guestbook.conf /etc/nginx/sites-available/guestbooksudo ln -s /etc/nginx/sites-available/guestbook /etc/nginx/sites-enabled/sudo rm /etc/nginx/sites-enabled/defaultsudo nginx -tsudo systemctl reload nginxLine by line
- Ubuntu keeps every site config here…
- …and turns a site on with a link in sites-enabled.
- Remove the “Welcome to nginx” page.
- Test the configuration. Always run this before reloading; a typo here takes every site down.
- Apply the new config without dropping open connections.
Here is the config. One server block for each name:
# nginx/guestbook.conf# /etc/nginx/sites-available/guestbook# The website: example.com and www.example.com -> Next.js on port 3000server { listen 80; listen [::]:80; server_name example.com www.example.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }} # The API: api.example.com -> Express on port 4000server { listen 80; listen [::]:80; server_name api.example.com; client_max_body_size 1m; location / { proxy_pass http://127.0.0.1:4000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}Line by line
- This block answers requests for the website names.
- Forward to the Next.js container through the loopback port Compose published.
- Pass on the name the visitor typed, so the app knows its own domain.
- Tell the app whether the visitor used https. Express reads this because of
trust proxy. - Let WebSocket connections pass through, in case you add live features later.
- The second name on the same server goes to Express.
- Refuse request bodies over 1 MB before they reach Node.js.
Open http://example.com in your browser. You should see the guestbook, without a padlock for now. If you do, DNS, the security group, Nginx and Docker are all working. The site isn't secure yet, so let's fix that next.
Step 8: Free SSL with Let's Encrypt
HTTPS encrypts everything between the visitor and your server, and browsers show “Not secure” without it. Let's Encrypt gives out free certificates, and Certbot gets one and edits your Nginx config for you. Install it with snap, as the Certbot site recommends. On the server:
# on the serversudo snap install --classic certbotsudo ln -s /snap/bin/certbot /usr/local/bin/certbotsudo certbot --nginx -d example.com -d www.example.com -d api.example.comsudo certbot renew --dry-runsystemctl list-timers | grep certbotLine by line
- Snap keeps Certbot up to date on its own.
- Ask for one certificate covering all three names. Certbot proves you control them over port 80, then adds
listen 443 ssland the certificate paths to your Nginx config. - Rehearse a renewal without changing anything. Certificates last 90 days.
- Certbot installs a timer that renews certificates automatically before they expire. You don't need your own cron job.
Certbot asks for an email (for expiry warnings) and whether to redirect HTTP to HTTPS. Choose redirect. Reload https://example.com: the padlock is there, and http:// now jumps to https:// by itself.
Step 9: Automatic updates, path A: build on the server
Updating by hand means SSH, git pull, rebuild, every time. GitHub Actions can do it for you: on every push to main, a GitHub runner logs in to your server over SSH and runs the same commands. This first path is the simplest: the server builds the new images itself.
GitHub needs a key that can log in to the server. Make a separate key pair just for deploys on your computer, and give the public half to the server:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/guestbook_deploy -N ""ssh-copy-id -i ~/.ssh/guestbook_deploy.pub guestbookcat ~/.ssh/guestbook_deployLine by line
- A new key used only by GitHub Actions. You can delete it later without locking yourself out.
- Adds the public key to ~/.ssh/authorized_keys on the server, using your existing
guestbookshortcut. - Print the private key. Copy all of it, including the BEGIN and END lines.
In your repository, open Settings → Secrets and variables → Actions → New repository secret and add three secrets: SERVER_HOST (your Elastic IP), SERVER_USER (ubuntu) and SERVER_SSH_KEY (the private key you just copied). Then add the workflow:
# .github/workflows/deploy-ssh.ymlname: Deploy (build on the server) on: push: branches: [main] workflow_dispatch: concurrency: group: deploy cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest steps: - name: Pull and rebuild on EC2 uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} command_timeout: 30m script: | set -e cd ~/guestbook git fetch origin main git reset --hard origin/main docker compose up -d --build docker image prune -f docker compose psLine by line
- Run on every push to main.
- Also add a “Run workflow” button in the Actions tab, for redeploying by hand.
- If you push twice quickly, the second deploy waits for the first instead of both building at once.
- A popular action that opens an SSH session and runs a script.
- Secrets are encrypted by GitHub and hidden in the logs.
- A Next.js build on a small server can take several minutes; the default 10 minute limit can be too short.
- Stop at the first failing command, so a broken build never restarts the app.
- Make the server's copy exactly match GitHub, even if someone edited files on the server.
- Rebuild the images that changed and replace only those containers. The notes volume stays.
- Delete the old, unused images, or the disk fills up after a few dozen deploys.
Commit, push, and open the Actions tab to watch it run. From now on, git push is your deploy button.
Step 10: Path B, level up: build in Actions, pull on the server
Path A has a weakness: the server does the heaviest work, the build. On a small instance the site can slow down for minutes during every deploy, and a big build can run out of memory. Path B moves the build to GitHub's runners (which have 7 GB of memory or more) and pushes the finished images to the GitHub Container Registry (GHCR). The server only downloads them and swaps the containers, in seconds.
First, a second Compose file that uses images instead of building them:
# docker-compose.prod.ymlservices: api: image: ghcr.io/your-github-user/guestbook-api:latest environment: PORT: 4000 DATA_DIR: /app/data CORS_ORIGIN: https://example.com,https://www.example.com volumes: - notes-data:/app/data ports: - "127.0.0.1:4000:4000" healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:4000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 30s timeout: 5s retries: 3 restart: unless-stopped web: image: ghcr.io/your-github-user/guestbook-web:latest environment: API_URL: http://api:4000 ports: - "127.0.0.1:3000:3000" depends_on: api: condition: service_healthy restart: unless-stopped volumes: notes-data:Line by line
- Instead of
build:, pull a ready image from GHCR. The name must be all lowercase. - The web image already has NEXT_PUBLIC_API_URL inside it, set during the build on GitHub.
Then the workflow, with two jobs: build (both images in parallel) and deploy (only after both builds succeed):
# .github/workflows/deploy-ghcr.ymlname: Deploy (build in Actions, pull on the server) on: push: branches: [main] workflow_dispatch: concurrency: group: deploy cancel-in-progress: false env: REGISTRY: ghcr.io OWNER: your-github-user jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write strategy: matrix: service: [api, web] steps: - uses: actions/checkout@v7 - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v7 with: context: ./${{ matrix.service }} push: true build-args: | NEXT_PUBLIC_API_URL=https://api.example.com tags: | ${{ env.REGISTRY }}/${{ env.OWNER }}/guestbook-${{ matrix.service }}:latest ${{ env.REGISTRY }}/${{ env.OWNER }}/guestbook-${{ matrix.service }}:${{ github.sha }} cache-from: type=gha,scope=${{ matrix.service }} cache-to: type=gha,mode=max,scope=${{ matrix.service }} deploy: needs: build runs-on: ubuntu-latest steps: - name: Pull the new images on EC2 uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} script: | set -e cd ~/guestbook git fetch origin main git reset --hard origin/main docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d docker image prune -f docker compose -f docker-compose.prod.yml psLine by line
- Allow this job to push images to GHCR using the built-in GITHUB_TOKEN. No extra secret needed.
- Run the same steps twice in parallel: once for api, once for web.
- Set up BuildKit, Docker's modern builder, which supports caching.
- Log in to ghcr.io as the user who triggered the run.
- Build from the api or web folder, depending on the matrix entry.
- Bake the public API address into the web build. The API image ignores it.
- Two tags:
latest, which the server pulls… - …and the commit id, so you can roll back to any earlier build.
- Reuse layers from the last run, stored in GitHub's cache, so unchanged steps are skipped.
- Deploy only if both images were built and pushed.
- Update the Compose file itself on the server.
- Download the new images. Only changed layers are transferred.
- Recreate only the containers whose image changed.
New GHCR packages are private by default, so the server must log in once to pull them. On GitHub, create a classic personal access token with only the read:packages scope, then on the server:
# on the server (once)echo YOUR_TOKEN | docker login ghcr.io -u your-github-user --password-stdinLine by line
- Read the token from stdin, so it doesn't end up in your shell history. Docker saves the login in ~/.docker/config.json.
Delete deploy-ssh.yml when you switch, so the two workflows don't both run on every push. Which path should you pick?
- Where the build runs: A: on your server. B: on GitHub's runners.
- Server size needed: A: 2 GB memory, or 1 GB plus swap. B: 1 GB is fine.
- Site during a deploy: A: slow for a few minutes while the CPU builds. B: a few seconds of container swap.
- Setup: A: three secrets and one workflow. B: plus a second Compose file and a registry login.
- Rollback: A: revert the commit and wait for a rebuild. B: point the server at an older
:shatag and restart. - Start with A while you learn; move to B when deploys feel slow or the server runs out of memory.
When something goes wrong
Walk the map from the top. These are the problems almost everyone hits once:
- The browser can't find the site at all. DNS isn't pointing at the server yet. Run
dig +short example.com @8.8.8.8; if the IP is wrong, recheck the GoDaddy records. - The connection times out. The security group is missing port 80 or 443, or the instance is stopped. Remember: an Elastic IP stays the same, a normal public IP does not.
- 502 Bad Gateway. Nginx is fine but nothing answers behind it. Run
docker compose psanddocker compose logs --tail=50 webon the server. - The page loads, but the notes fail with a CORS error in the browser console.
CORS_ORIGINdoesn't exactly match the address in the browser bar (https vs http, www vs bare). - The server page is empty or errors with ECONNREFUSED.
API_URLsayslocalhost. Inside a container that is the container itself; use the service name,http://api:4000. - The build stops with “Killed” or exit code 137. Out of memory. Add swap (Step 3), use a bigger instance, or move to path B.
- The form posts to the old API address.
NEXT_PUBLIC_API_URLis set at build time. Change the build argument and rebuild; restarting is not enough. - “No space left on device.” Old images. Run
docker system df, thendocker image prune -a(it removes every image no container uses). - The deploy job fails with “ssh: handshake failed”. Wrong
SERVER_SSH_KEY(copy the whole private key), or port 22 is closed to GitHub's IPs.
Three commands answer most questions on the server: docker compose logs -f, sudo tail -f /var/log/nginx/error.log and curl -I http://127.0.0.1:3000.
How this website runs
This site, winechanz.me, uses exactly this recipe, with three services instead of two:
- One EC2 server with an Elastic IP, Ubuntu, Docker Engine and Nginx on the host.
- Three containers from one Compose file: the public site (Next.js) on 3000, the admin panel (Next.js) on 3001, and the API (Express + MongoDB Atlas) on 4000, all published on 127.0.0.1 only.
- Three names, each an A record at the domain host: the bare domain,
admin.andapi., all on one Let's Encrypt setup. - Path A for deploys: a push to the
prodbranch runs a GitHub Actions job that SSHes in, resets to the branch and runsdocker compose up -d --build. - One lesson learned the hard way: the journal showed no entries on the new server, while the API answered fine. The site's
API_URLhad been copied from a local.envashttp://localhost:4000. Setting it tohttp://api:4000in the Compose file fixed it.
The launch checklist
- The app runs with
docker compose upon your own computer. - A budget alert exists in AWS Billing.
- The EC2 instance runs Ubuntu 24.04 with 20 GB disk and an Elastic IP.
- The security group allows 80 and 443 from anywhere, and 22 only as you intend.
- Swap is on, and
docker compose versionworks without sudo. dig +shortreturns your Elastic IP for the domain,wwwandapi.sudo nginx -tpasses, andhttp://example.comshows the app.- Certbot issued the certificate,
renew --dry-runpasses, and HTTP redirects to HTTPS. - A test commit to
mainappears on the live site without you touching the server.
That's the whole trip, from localhost to a real address with a padlock that updates itself. None of the pieces are magic: a rented Linux computer, a phone-book entry, a proxy, a certificate and a script that runs when you push. Once you have done it once, the next project takes an evening, not a week.
ကိုယ့် Next.js site က localhost:3000 မှာ run နေတယ်။ Express API က localhost:4000 မှာ ပြန်ဖြေနေတယ်။ အားလုံး အလုပ်လုပ်နေပေမဲ့ ကမ္ဘာပေါ်မှာ တခြားဘယ်သူမှ မမြင်ရသေးပါဘူး။ အဲဒီအချိန်ကနေ https://example.com လို တကယ့်လိပ်စာ တစ်ခုအထိ ရောက်ဖို့ ပထမဆုံးအကြိမ်မှာ အရမ်းဝေးသလို ခံစားရပါတယ် — server၊ DNS၊ port၊ certificate၊ pipeline။ Tutorial တိုင်းက ကိုယ်ပိတ်မိနေတဲ့ အဆင့်ကိုပဲ ကျော်သွားသလိုပါပဲ။
ဒီ guide က အဲဒီကွာဟချက်ကို တစ်ထိုင်တည်းနဲ့ ပိတ်ပေးပါမယ်။ Next.js + Express app အသေးလေး တစ်ခုကို Docker ထဲထည့်မယ်၊ AWS EC2 မှာ Linux server တစ်လုံး ငှားမယ်၊ GoDaddy domain ကို အဲဒီ server ဆီ ညွှန်မယ်၊ ရှေ့မှာ Nginx ထားမယ်၊ အခမဲ့ Let's Encrypt certificate ထည့်မယ်၊ နောက်ဆုံး GitHub Actions နဲ့ git push လုပ်တိုင်း live site ကို အလိုအလျောက် update ဖြစ်အောင် လုပ်ပါမယ်။ ဒီ website ကိုယ်တိုင်လည်း ဒီ setup နဲ့ပဲ run နေပါတယ်။
ဘာကို တည်ဆောက်မှာလဲ
App က guestbook အသေးလေးပါ။ Next.js front end က note တွေကို ပြပြီး note အသစ်ထည့်ဖို့ form တစ်ခု ပါပါတယ်။ Express API က note တွေကို JSON file ထဲမှာ သိမ်းပါတယ်။ ငါးမိနစ်နဲ့ ဖတ်လို့ပြီးလောက်အောင် သေးပေမဲ့ တကယ့် project တစ်ခုမှာ ပါတဲ့ အစိတ်အပိုင်း အားလုံး ပါပါတယ် — server မှာ render လုပ်တဲ့ page၊ browser ကနေ API ကို ခေါ်တာ၊ environment variable တွေနဲ့ redeploy လုပ်ရင်တောင် မပျောက်ရမယ့် data။
Visitor တစ်ယောက်ရဲ့ request တစ်ခုက သူ့ browser ကနေ ကိုယ့် code အထိ သွားရတဲ့ ခရီးတစ်ခုလုံး ဒီမှာပါ —
Browser: https://example.com | | 1. "Where is example.com?" vGoDaddy DNS -> answers 203.0.113.10 (your Elastic IP) | | 2. HTTPS request to 203.0.113.10:443 vAWS EC2 (Ubuntu) - security group allows 22, 80, 443 | vNginx (on the server) - holds the SSL certificate |-- example.com -> 127.0.0.1:3000 -> [web container: Next.js] '-- api.example.com -> 127.0.0.1:4000 -> [api container: Express] | volume: notes-data GitHub: git push -> Actions -> SSH into EC2 -> docker compose upတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- DNS က internet ရဲ့ ဖုန်းစာအုပ်ပါ။ ကိုယ့်နာမည်ကို server ရဲ့ IP address အဖြစ် ပြောင်းပေးတဲ့ entry ကို GoDaddy က သိမ်းထားပါတယ်။
- Server ကို ဝိုင်းထားတဲ့ AWS firewall ပါ။ ဒီ port တွေကိုပဲ internet ကနေ ရောက်လို့ရပါတယ်။
- Nginx က HTTPS အလုပ်ကို လုပ်ပြီး စက်တစ်လုံးတည်းပေါ်က container တွေနဲ့ ရိုးရိုး HTTP နဲ့ ဆက်သွယ်ပါတယ်။
- Server တစ်လုံး၊ နာမည် နှစ်ခု။ Request ထဲက နာမည်ကို Nginx က ဖတ်ပြီး မှန်တဲ့ container ကို ရွေးပါတယ်။
- Container ကို ပြန် build လုပ်ရင်တောင် Docker volume က note file ကို ထိန်းထားပေးပါတယ်။
- နောက်ဆုံးအဆင့် — main ကို push လုပ်တိုင်း app ကို အလိုအလျောက် ပြန် build ပြီး restart လုပ်ပါတယ်။
ဒီမြေပုံကို မှတ်ထားပါ။ အောက်က အပိုင်းတိုင်းက မြေပုံထဲက အကွက်တစ်ကွက်စီကို တည်ဆောက်တာပါ။ နောက်ပိုင်း တစ်ခုခု ပျက်ရင် မြေပုံကို အပေါ်ကနေ အောက်ကို လိုက်ကြည့်ပြီး ဘယ်နေရာမှာလဲ ရှာနိုင်ပါတယ်။
မစခင် ပြင်ဆင်ရမယ့်အရာများ
- AWS account တစ်ခု။ aws.amazon.com မှာ sign up လုပ်ပါ။ Free plan အတွက်တောင် card တစ်ခု လိုပါတယ်။
- GoDaddy မှာ domain တစ်ခု။ ဘယ်နာမည်ဖြစ်ဖြစ် ရပါတယ်၊ ဒီမှာတော့
example.comကို သုံးပါမယ်။ - GitHub account နဲ့ ကိုယ့် project ကို repository တစ်ခုထဲမှာ ထားထားဖို့။
- Docker Desktop — server မတိုင်ခင် container တွေကို ကိုယ့်ကွန်ပျူတာမှာ စမ်းဖို့။
sshပါတဲ့ terminal (macOS၊ Linux နဲ့ Windows 10+ မှာ ပါပြီးသား)။
အဆင့် ၁ — Docker အတွက် အသင့်ဖြစ်နေတဲ့ app
Repository က ဒီလိုပုံစံပါ — service တစ်ခုချင်းအတွက် folder နှစ်ခုနဲ့ deploy file တွေက အပေါ်ဆုံးမှာ —
guestbook/|-- api/ Express API| |-- src/server.js| |-- package.json| '-- Dockerfile|-- web/ Next.js front end| |-- app/page.tsx| |-- app/note-form.tsx| |-- next.config.ts| '-- Dockerfile|-- nginx/guestbook.conf copied to the server later|-- docker-compose.yml path A: build on the server|-- docker-compose.prod.yml path B: pull ready images'-- .github/workflows/ automatic deploysExpress API
Route သုံးခုပါတဲ့ ရိုးရိုး Express 5 server ပါ။ Production အတွက် အရေးကြီးတဲ့ အချက်နှစ်ချက် ရှိပါတယ် — setting တွေကို environment variable ကနေ ဖတ်တယ်၊ ရှေ့က proxy ကို ယုံကြည်တယ်။
// api/src/server.jsimport { mkdir, readFile, writeFile } from 'node:fs/promises';import path from 'node:path';import cors from 'cors';import express from 'express'; const PORT = Number(process.env.PORT ?? 4000);const DATA_DIR = process.env.DATA_DIR ?? './data';const FILE = path.join(DATA_DIR, 'notes.json');const ORIGINS = (process.env.CORS_ORIGIN ?? 'http://localhost:3000').split(','); async function load() { try { return JSON.parse(await readFile(FILE, 'utf8')); } catch { return []; }} async function save(notes) { await mkdir(DATA_DIR, { recursive: true }); await writeFile(FILE, JSON.stringify(notes, null, 2));} const app = express();app.set('trust proxy', 1);app.use(cors({ origin: ORIGINS }));app.use(express.json({ limit: '10kb' })); app.get('/health', (req, res) => { res.json({ ok: true, uptime: Math.round(process.uptime()) });}); app.get('/api/notes', async (req, res) => { const notes = await load(); res.json(notes.slice(-50).reverse());}); app.post('/api/notes', async (req, res) => { const name = String(req.body?.name ?? '').trim().slice(0, 40); const message = String(req.body?.message ?? '').trim().slice(0, 280); if (!name || !message) { return res.status(400).json({ error: 'Name and message are required.' }); } const notes = await load(); const note = { id: Date.now(), name, message, at: new Date().toISOString() }; notes.push(note); await save(notes); res.status(201).json(note);}); app.listen(PORT, '0.0.0.0', () => { console.log(`API listening on port ${PORT}`);});တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Setting တွေကို environment variable ကနေ ယူပြီး local development အတွက် default တန်ဖိုးတွေ ပါပါတယ်။ တကယ့်တန်ဖိုးတွေကို Docker Compose က သတ်မှတ်ပေးပါတယ်။
- Browser ကနေ ဒီ API ကို ခေါ်ခွင့်ရှိတဲ့ website တွေ (CORS)။ Production မှာတော့ ကိုယ့် https:// domain ပါ။
- Express ရှေ့မှာ Nginx ရှိပါတယ်။ Nginx ထည့်ပေးတဲ့ X-Forwarded-* header တွေကနေ visitor ရဲ့ တကယ့် IP နဲ့ protocol ကို ဖတ်ဖို့ Express ကို ပြောတာပါ။
- “အသက်ရှင်နေတယ်” လို့ ပြောတဲ့ route အသေးလေးပါ။ Container ကို စစ်ဖို့ Docker က သုံးပြီး debug လုပ်တဲ့အခါ curl နဲ့ ခေါ်ကြည့်လို့ရပါတယ်။
- Express 5 က async handler ထဲက error တွေကို သူ့ဘာသာ ဖမ်းပေးလို့ ဒီမှာ try/catch wrapper မလိုပါဘူး။
- Network interface အားလုံးပေါ်မှာ နားထောင်ပါတယ်။ Container ထဲမှာ 127.0.0.1 ဆိုရင် container ကိုယ်တိုင်ကနေပဲ ရောက်လို့ရပါတယ်။
# api/DockerfileFROM node:22-alpineWORKDIR /appENV NODE_ENV=production COPY package.json package-lock.json ./RUN npm ci --omit=dev COPY src ./srcRUN mkdir -p /app/data && chown -R node:node /app/dataUSER node EXPOSE 4000CMD ["node", "src/server.js"]တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Alpine Linux ပေါ်က တရားဝင် Node.js 22 image (60 MB လောက်) ကနေ စပါတယ်။
- Package file တွေကိုပဲ အရင် copy ကူးပါတယ်။ Docker က ဒီ layer ကို cache လုပ်ထားလို့ dependency ပြောင်းမှပဲ
npm ciပြန် run ပါတယ်။ - Lockfile မှာ ပါတဲ့အတိုင်း အတိအကျ install လုပ်ပြီး dev dependency တွေ မထည့်ပါဘူး။
- Data folder ကို ဖန်တီးပြီး root မဟုတ်တဲ့
nodeuser ကို ပေးလိုက်လို့ app က note တွေကို ရေးလို့ရပါတယ်။ - Root အနေနဲ့ မဟုတ်ဘဲ ရိုးရိုး user အနေနဲ့ run ပါတယ်။ App ကနေ တစ်ယောက်ယောက် ဖောက်ဝင်လာရင်တောင် လုပ်နိုင်တာ အများကြီး နည်းသွားပါတယ်။
Next.js front end
Setting တစ်ခုတည်းက Next.js app ကို container ထဲ သပ်သပ်ရပ်ရပ် ဝင်အောင် လုပ်ပေးပါတယ် — output: 'standalone'။ Build လုပ်တဲ့အခါ server.js အသေးလေးနဲ့ app က တကယ်သုံးတဲ့ node_modules file တွေပဲ ပါတဲ့ folder တစ်ခုကို ထုတ်ပေးပါတယ်။
// web/next.config.tsimport type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'standalone',}; export default nextConfig;တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- .next/standalone ထဲမှာ သီးသန့် server တစ်ခု build လုပ်ပါတယ်။ နောက်ဆုံး image မှာ npm ဒါမှမဟုတ် node_modules အပြည့်အစုံ မလိုတော့ပါဘူး။
Page က server ပေါ်မှာ run လို့ API ကို internal လိပ်စာနဲ့ ခေါ်လို့ရပါတယ်။ Form ကတော့ visitor ရဲ့ browser ထဲမှာ run လို့ public လိပ်စာကို သုံးရပါမယ်။ “Local မှာ အလုပ်လုပ်ပေမဲ့ server ပေါ်မှာ မလုပ်ဘူး” ဆိုတဲ့ bug အများဆုံးက ဒီကွာခြားချက်ကနေ လာတာမို့ သေချာကြည့်ပါ —
// web/app/page.tsximport NoteForm from './note-form'; export const dynamic = 'force-dynamic'; type Note = { id: number; name: string; message: string; at: string }; async function getNotes(): Promise<Note[]> { const res = await fetch(`${process.env.API_URL}/api/notes`, { cache: 'no-store' }); if (!res.ok) throw new Error(`API answered ${res.status}`); return res.json();} export default async function Home() { const notes = await getNotes(); return ( <main> <h1>Guestbook</h1> <p>Leave a note. It travels from your browser to Nginx, to Express, and back.</p> <NoteForm /> <ul> {notes.map((note) => ( <li key={note.id}> <strong>{note.name}</strong> {note.message} </li> ))} </ul> </main> );}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Request တိုင်းမှာ render လုပ်ပါ။ ဒါမပါရင် Next.js က API မ run သေးတဲ့ build အချိန်မှာ page ကို render လုပ်ဖို့ ကြိုးစားပါလိမ့်မယ်။
- Request အချိန်မှာ server ပေါ်ကနေ ဖတ်ပါတယ်။ Docker ထဲမှာ ဒါက http://api:4000 ဖြစ်ပြီး internal network ပေါ်က API container ရဲ့ နာမည်ပါ။
// web/app/note-form.tsx'use client'; import { useRouter } from 'next/navigation';import { useState } from 'react'; const API = process.env.NEXT_PUBLIC_API_URL; export default function NoteForm() { const router = useRouter(); const [error, setError] = useState(''); async function send(form: FormData) { setError(''); const res = await fetch(`${API}/api/notes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: form.get('name'), message: form.get('message') }), }); if (!res.ok) return setError((await res.json()).error ?? 'Something went wrong.'); router.refresh(); } return ( <form action={send}> <input name="name" placeholder="Your name" maxLength={40} required /> <input name="message" placeholder="Say hello" maxLength={280} required /> <button type="submit">Sign</button> {error && <p role="alert">{error}</p>} </form> );}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- ဒီ component က browser ထဲမှာ run ပါတယ်။
- NEXT_PUBLIC_ variable တွေကို build အချိန်မှာ JavaScript ထဲ ကူးထည့်ပါတယ်။ Browser က http://api:4000 ကို မရောက်နိုင်လို့ ဒါက public https://api.example.com ဖြစ်ရပါမယ်။
- Note အသစ် ပေါ်လာအောင် server page ကို ပြန် render လုပ်ဖို့ Next.js ကို ပြောတာပါ။
# web/Dockerfile# ---- 1. Install dependencies ----FROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci # ---- 2. Build the app ----FROM node:22-alpine AS builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .ARG NEXT_PUBLIC_API_URLENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URLENV NEXT_TELEMETRY_DISABLED=1RUN npm run build # ---- 3. Run it, with only what it needs ----FROM node:22-alpine AS runnerWORKDIR /appENV NODE_ENV=productionENV PORT=3000ENV HOSTNAME=0.0.0.0COPY --from=builder /app/public ./publicCOPY --from=builder --chown=node:node /app/.next/standalone ./COPY --from=builder --chown=node:node /app/.next/static ./.next/staticUSER nodeEXPOSE 3000CMD ["node", "server.js"]တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Multi-stage build — file တစ်ခုထဲမှာ ယာယီ image သုံးခု။ နောက်ဆုံးတစ်ခုကိုပဲ သိမ်းထားပါတယ်။
- Public API လိပ်စာကို build argument အနေနဲ့ လက်ခံပြီး
next buildကို ပေးပါတယ်။ - အလေးဆုံးအဆင့်ပါ။ Memory 1 GB ထက် ပိုလိုလို့ server အသေးမှာ အရေးကြီးပါတယ် (အဆင့် ၃ ကို ကြည့်ပါ)။
- နောက်ဆုံး image က သန့်သန့်ရှင်းရှင်း စပြီး build output ကိုပဲ copy ကူးပါတယ် — source code မပါ၊ dev tool မပါ။
- API လိုပဲ အကြောင်းပြချက်နဲ့ standalone server ကို interface အားလုံးပေါ်မှာ နားထောင်စေပါတယ်။
- Standalone build က ထုတ်ပေးတဲ့ သီးသန့် server ပါ။
- Standalone output မှာ static asset တွေ မပါလို့ ဘေးမှာ copy ကူးထည့်ပေးရပါတယ်။
- ရိုးရိုး Node.js နဲ့ app ကို စတင်ပါတယ်။
next startမလိုပါဘူး။
Local က node_modules၊ .next နဲ့ .env file တွေ image ထဲ ဘယ်တော့မှ မပါသွားအောင် Dockerfile တစ်ခုချင်းရဲ့ ဘေးမှာ .dockerignore ထည့်ပါ —
# web/.dockerignorenode_modules.next.env*npm-debug.logDocker Compose နဲ့ ချိတ်ဆက်ခြင်း
Compose က container နှစ်ခုလုံးကို command တစ်ခုတည်းနဲ့ စတင်ပေးပြီး တစ်ခုနဲ့တစ်ခု နာမည်နဲ့ ခေါ်လို့ရတဲ့ private network ပေါ်မှာ ထားပေးကာ setting အားလုံးကို မှတ်ထားပေးပါတယ် —
# docker-compose.ymlservices: api: build: ./api environment: PORT: 4000 DATA_DIR: /app/data CORS_ORIGIN: https://example.com,https://www.example.com volumes: - notes-data:/app/data ports: - "127.0.0.1:4000:4000" healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:4000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 30s timeout: 5s retries: 3 restart: unless-stopped web: build: context: ./web args: NEXT_PUBLIC_API_URL: https://api.example.com environment: API_URL: http://api:4000 ports: - "127.0.0.1:3000:3000" depends_on: api: condition: service_healthy restart: unless-stopped volumes: notes-data:တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- ကိုယ့်တကယ့် website ကပဲ browser ကနေ API ကို ခေါ်ခွင့်ရှိပါတယ်။
- Note တွေကို named volume ထဲမှာ သိမ်းပါတယ်။ Container ကို ပြန် build ဒါမှမဟုတ် အစားထိုးရင်တောင် data မပျောက်ပါဘူး။
- Server ရဲ့ loopback ပေါ်မှာပဲ port ကို ဖွင့်ပါတယ်။ Nginx ရောက်လို့ရပြီး internet ကတော့ မရောက်နိုင်ပါဘူး။ Docker က firewall ကို ဖောက်ပြီး port ဖွင့်တာကိုလည်း ဒါက တားပေးပါတယ်။
- Docker က စက္ကန့် ၃၀ တိုင်း /health ကို ခေါ်ပြီး container ကို healthy ဒါမှမဟုတ် unhealthy လို့ မှတ်ပါတယ်။
- Web build ထဲကို ပေးလိုက်တဲ့ public လိပ်စာပါ။
- Server အချင်းချင်း ခေါ်တဲ့အခါ service နာမည်
apiကို သုံးပါတယ်။ Container ထဲမှာlocalhostဆိုတာ အဲဒီ container ကိုယ်တိုင်ပါ။ - API က health check အောင်မှပဲ web container ကို စတင်ပါတယ်။
- App crash ဖြစ်ရင် ဒါမှမဟုတ် server reboot ဖြစ်ရင် Docker က ပြန်စတင်ပေးပါတယ်။
ဘာမှ မငှားခင် ကိုယ့်စက်ပေါ်မှာ အလုပ်လုပ်ကြောင်း အရင် သက်သေပြပါ။ Local test အတွက် public URL နေရာမှာ http://localhost:4000 ကို သုံးပါ (example.com စာကြောင်း နှစ်ကြောင်းကို ခဏပြင်ပါ၊ ဒါမှမဟုတ် app တွေကို npm run dev နဲ့ run ပါ) —
docker compose up -d --builddocker compose pscurl http://localhost:4000/healthopen http://localhost:3000တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Image နှစ်ခုလုံး build လုပ်ပြီး container တွေကို နောက်ကွယ်မှာ (-d) စတင်ပါတယ်။
- Service နှစ်ခုလုံး “running” ဖြစ်ရမှာဖြစ်ပြီး API က “(healthy)” ပြရပါမယ်။
အဆင့် ၂ — EC2 server တစ်လုံး launch လုပ်ခြင်း
EC2 (Elastic Compute Cloud) က AWS data center ထဲက virtual ကွန်ပျူတာ တစ်လုံးကို ငှားပေးပါတယ်။ ကိုယ့် Linux၊ ကိုယ့် port တွေ၊ ကိုယ့် Docker — အားလုံး ကိုယ်ပိုင် ထိန်းချုပ်ရပါတယ်။ AWS Console ကို ဖွင့်ပြီး ညာဘက်အပေါ် menu မှာ visitor တွေနဲ့ နီးတဲ့ Region ကို ရွေးပါ (ဥပမာ Singapore၊ ap-southeast-1)၊ ပြီးရင် EC2 → Instances → Launch instances ကို သွားပါ။
- Name:
guestbook။ - Application and OS image: Ubuntu Server 24.04 LTS၊ 64-bit (x86)။ LTS version တွေက ငါးနှစ်ကြာ security update ရပါတယ်။
- Instance type:
t3.small(memory 2 GB) က အဆင်ပြေပါတယ်။t3.micro(1 GB) လည်း ရပေမဲ့ swap (အဆင့် ၃) နဲ့ ဒါမှမဟုတ် GitHub ပေါ်မှာ build လုပ်တဲ့ path B နဲ့မှပဲ ရပါတယ်။ - Key pair: အသစ်တစ်ခု ဖန်တီးပါ၊ type ED25519၊ format
.pem။ Browser ကguestbook.pemကို တစ်ကြိမ်ပဲ download လုပ်ပေးပြီး AWS မှာ copy မကျန်လို့ လုံခြုံအောင် သိမ်းထားပါ။ - Network settings → Edit: inbound rule သုံးခုပါတဲ့ security group တစ်ခု ဖန်တီးပါ — SSH (22) ကို My IP ကနေ၊ HTTP (80) ကို ဘယ်နေရာကမဆို၊ HTTPS (443) ကို ဘယ်နေရာကမဆို။
- Storage: 20 GB gp3။ Docker image တွေက default 8 GB ကို မြန်မြန် ပြည့်သွားစေပါတယ်။
- Launch instance ကို နှိပ်ပြီး state က Running ဖြစ်တဲ့အထိ စောင့်ပါ။
မပြောင်းလဲတဲ့ လိပ်စာ ပေးခြင်း — Elastic IP
ရိုးရိုး EC2 public IP က instance ကို stop/start လုပ်တိုင်း ပြောင်းသွားပါတယ်။ Domain အတွက် ဘယ်တော့မှ မရွေ့တဲ့ လိပ်စာ လိုပါတယ်။ EC2 → Elastic IPs → Allocate Elastic IP address → Allocate ကို သွားပြီး အဲဒါကို ရွေးကာ Actions → Associate Elastic IP address ကို နှိပ်ပြီး guestbook instance ကို ရွေးပါ။ လိပ်စာကို မှတ်ထားပါ — ဥပမာအနေနဲ့ 203.0.113.10 ကို သုံးပါမယ်။
အဆင့် ၃ — ချိတ်ဆက်ပြီး server ကို ပြင်ဆင်ခြင်း
ကိုယ့်ကွန်ပျူတာပေါ်မှာ key file ကို ကာကွယ်ပါ (ဘယ်သူမဆို ဖတ်လို့ရတဲ့ key ကို SSH က လက်မခံပါဘူး)၊ ပြီးရင် default ubuntu user နဲ့ login ဝင်ပါ —
chmod 400 ~/Downloads/guestbook.pemmv ~/Downloads/guestbook.pem ~/.ssh/ssh -i ~/.ssh/guestbook.pem ubuntu@203.0.113.10တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- ကိုယ်တစ်ယောက်တည်းပဲ key ကို ဖတ်ခွင့်ရှိပါတယ်။ ဒါမပါရင် ssh က “UNPROTECTED PRIVATE KEY FILE” နဲ့ ရပ်သွားပါတယ်။
- ပထမဆုံးအကြိမ်မှာ server ရဲ့ fingerprint ကို ယုံကြည်ဖို့ “yes” လို့ ရိုက်ပါ။
အဲဒါကို အမြဲရိုက်ရတာ ပင်ပန်းပါတယ်။ ကိုယ့်ကွန်ပျူတာရဲ့ ~/.ssh/config မှာ shortcut တစ်ခု ထည့်လိုက်ရင် နောက်ပိုင်း ssh guestbook ဆိုရင် လုံလောက်ပါပြီ —
# ~/.ssh/configHost guestbook HostName 203.0.113.10 User ubuntu IdentityFile ~/.ssh/guestbook.pemအခု server ပေါ်မှာ update တွေ install လုပ်ပြီး swap file တစ်ခု ထည့်ပါ။ Swap ဆိုတာ system က နှေးတဲ့ memory အပိုအဖြစ် သုံးနိုင်တဲ့ disk နေရာပါ။ ဒါမပါရင် 1–2 GB server ပေါ်မှာ Next.js build က ရှင်းလင်းတဲ့ message မပါဘဲ တစ်ဝက်မှာ မကြာခဏ ရပ်သွားပါတယ်။
# on the serversudo apt update && sudo apt upgrade -ysudo fallocate -l 2G /swapfilesudo chmod 600 /swapfilesudo mkswap /swapfilesudo swapon /swapfileecho '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstabfree -hတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Security update တွေ install လုပ်ပါတယ်။ နောက်မှ reboot လုပ်ခိုင်းရင်
sudo rebootrun ပြီး ပြန်ချိတ်ပါ။ - Swap အတွက် disk ပေါ်မှာ 2 GB file တစ်ခု ကြိုယူထားပါတယ်။
- Root ပဲ ဖတ်ခွင့်ရှိပါတယ် — swap ထဲမှာ memory အပိုင်းအစတွေ ပါနိုင်လို့ပါ။
- Reboot လုပ်တိုင်း swap ကို ပြန်ဖွင့်ပေးပါတယ်။
- စစ်ကြည့်ပါ — Swap စာကြောင်းမှာ 2.0Gi ပြရပါမယ်။
အဆင့် ၄ — Server ပေါ်မှာ Docker install လုပ်ခြင်း
Ubuntu ရဲ့ docker.io package အဟောင်းကို မသုံးဘဲ Docker ရဲ့ ကိုယ်ပိုင် repository ကနေ Docker Engine ကို install လုပ်ပါ။ ဒါမှ version အသစ်နဲ့ docker compose plugin ကို ရပါတယ်။ Server ပေါ်မှာ —
# on the serversudo apt install -y ca-certificates curlsudo install -m 0755 -d /etc/apt/keyringssudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.ascsudo chmod a+r /etc/apt/keyrings/docker.ascecho "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.listsudo apt updatesudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginsudo usermod -aG docker $USERတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Package တွေ Docker ဆီက တကယ်လာတာ ဟုတ်မဟုတ် apt က စစ်နိုင်အောင် Docker ရဲ့ signing key ကို download လုပ်ပါတယ်။
- ဒီ Ubuntu version (24.04 အတွက် noble) အတွက် Docker repository ကို ထည့်ပါတယ်။
- Engine၊ CLI၊ Buildx နဲ့ Compose v2 plugin ကို တစ်ခါတည်း install လုပ်ပါတယ်။
- ubuntu user ကို sudo မပါဘဲ docker run ခွင့်ပေးပါတယ်။ နောက်တစ်ကြိမ် login ဝင်မှ အသက်ဝင်ပါတယ်။
Logout (exit) လုပ်ပြီး ssh guestbook နဲ့ ပြန်ဝင်ကာ အားလုံး ရှိမရှိ စစ်ပါ —
docker run --rm hello-worlddocker compose versionအဆင့် ၅ — Code ကို server ပေါ် ရောက်အောင် ပို့ခြင်း
Server က ကိုယ့် code ကို GitHub ကနေ pull လုပ်ပါတယ်။ Repository က private ဖြစ်ရင် server မှာ ခွင့်ပြုချက် လိုပါတယ်။ Deploy key ဆိုတာ ဒီ repository တစ်ခုတည်းကိုပဲ ဖတ်ခွင့်ရှိတဲ့ SSH key ပါ — ကိုယ့် personal key ကို server ပေါ်တင်တာထက် ပိုလုံခြုံပါတယ်။ Server ပေါ်မှာ —
# on the serverssh-keygen -t ed25519 -C "guestbook-server" -f ~/.ssh/id_ed25519 -N ""cat ~/.ssh/id_ed25519.pubတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Passphrase မပါတဲ့ key pair တစ်ခု ဖန်တီးပါတယ် — အလိုအလျောက် deploy တွေ သုံးနိုင်အောင်ပါ။
- ssh-ed25519 နဲ့ စတဲ့ ထွက်လာတဲ့ စာကြောင်းကို copy ကူးပါ။
GitHub မှာ ကိုယ့် repository → Settings → Deploy keys → Add deploy key ကို ဖွင့်ပြီး paste လုပ်ပါ၊ Allow write access ကို အမှန်ခြစ် မလုပ်ဘဲ save လုပ်ပါ။ ပြီးရင် clone လုပ်ပြီး app ကို စတင်ပါ —
# on the servergit clone git@github.com:your-github-user/guestbook.git ~/guestbookcd ~/guestbookdocker compose up -d --buildcurl http://127.0.0.1:4000/healthcurl -I http://127.0.0.1:3000တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Deploy key ကို သုံးနိုင်အောင် https မဟုတ်ဘဲ SSH လိပ်စာ (git@github.com:…) ကို သုံးပါ။
- Server အသေးပေါ်မှာ ပထမဆုံး build က မိနစ်အနည်းငယ် ကြာပါတယ်။ နောက်ပိုင်း build တွေက cache လုပ်ထားတဲ့ layer တွေကို ပြန်သုံးပါတယ်။
HTTP/1.1 200 OKကို မျှော်လင့်ပါ။ App run နေပေမဲ့ လောလောဆယ် server ကိုယ်တိုင်ကပဲ ရောက်လို့ရပါသေးတယ်။
အဆင့် ၆ — GoDaddy domain ကို server ဆီ ညွှန်ခြင်း
အခု internet ရဲ့ ဖုန်းစာအုပ်ကို ကိုယ့် site ဘယ်မှာ ရှိလဲ သင်ပေးရပါမယ်။ A record က “ဒီနာမည် → ဒီ IPv4 address” လို့ ပြောပါတယ်။ သုံးခု လိုပါတယ် — domain သက်သက်၊ www နဲ့ api။
- GoDaddy ကို sign in ဝင်ပြီး My Products (Domain Portfolio) ကို ဖွင့်ကာ ကိုယ့် domain ကို ရွေးပြီး DNS → DNS Records ကို ဖွင့်ပါ။
- Name
@ရှိတဲ့ A record အဟောင်းကို ရှာပါ (“Parked” ကို ညွှန်နေတတ်ပါတယ်)။ ပြင်ပါ — Value = ကိုယ့် Elastic IP၊ TTL = 600 စက္ကန့် (ဒါမှမဟုတ် 1/2 hour)။ Save လုပ်ပါ။ - GoDaddy မှာ များသောအားဖြင့် CNAME record
www→@ရှိပါတယ်။ ဒီအတိုင်းထားပါ —wwwက@record နောက်ကို လိုက်ပါလိမ့်မယ်။ မရှိရင် A recordwww→ ကိုယ့် Elastic IP ကို ထည့်ပါ။ - Record တစ်ခု ထည့်ပါ — Type A၊ Name
api၊ Value = ကိုယ့် Elastic IP၊ TTL 600။ Save လုပ်ပါ။ - Host အဟောင်း ဒါမှမဟုတ် forwarding service လို တခြားနေရာကို ညွှန်နေတဲ့
@၊wwwဒါမှမဟုတ်apiအတွက် A ဒါမှမဟုတ် AAAA record တွေကို ဖျက်ပါ။ နာမည်တစ်ခုအတွက် အဖြေနှစ်ခု ရှိရင် visitor တွေ server မှားဆီ ကျပန်း ရောက်သွားပါလိမ့်မယ်။
DNS ပြောင်းလဲမှုတွေက များသောအားဖြင့် မိနစ်အနည်းငယ်အတွင်း ပျံ့နှံ့ပြီး တစ်ခါတလေ တစ်နာရီ နှစ်နာရီအထိ ကြာတတ်ပါတယ်။ ကိုယ့်ကွန်ပျူတာကနေ စစ်ကြည့်ပါ —
dig +short example.comdig +short www.example.comdig +short api.example.comတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- စာကြောင်းတိုင်းမှာ ကိုယ့် Elastic IP ကို ပြရပါမယ်။ Windows မှာ
nslookup example.comကို သုံးပါ။
အဆင့် ၇ — Nginx၊ ရှေ့တံခါး
လောလောဆယ် app က 127.0.0.1 ပေါ်မှာပဲ နားထောင်နေပါတယ်။ Nginx က public port 80 နဲ့ 443 ပေါ်မှာ နားထောင်ပြီး request တစ်ခုချင်းကို မှန်တဲ့ container ဆီ လွှဲပေးတဲ့ မြန်ဆန်တဲ့ web server ပါ။ ဒါကို reverse proxy လို့ ခေါ်ပါတယ်။ HTTPS၊ နှေးတဲ့ ဒါမှမဟုတ် ပျက်နေတဲ့ client တွေနဲ့ request အရွယ်အစား ကန့်သတ်ချက်တွေကိုလည်း ကိုင်တွယ်ပေးလို့ Node.js process တွေ လုပ်စရာ မလိုတော့ပါဘူး။ Server ပေါ်မှာ —
# on the serversudo apt install -y nginxsudo cp ~/guestbook/nginx/guestbook.conf /etc/nginx/sites-available/guestbooksudo ln -s /etc/nginx/sites-available/guestbook /etc/nginx/sites-enabled/sudo rm /etc/nginx/sites-enabled/defaultsudo nginx -tsudo systemctl reload nginxတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Ubuntu က site config တိုင်းကို ဒီမှာ သိမ်းပြီး…
- …sites-enabled ထဲမှာ link တစ်ခုနဲ့ site ကို ဖွင့်ပါတယ်။
- “Welcome to nginx” page ကို ဖယ်ရှားပါတယ်။
- Configuration ကို စစ်ပါတယ်။ Reload မလုပ်ခင် အမြဲ run ပါ — ဒီမှာ စာလုံးပေါင်းမှားတာ တစ်ခုက site အားလုံးကို ပျက်စေနိုင်ပါတယ်။
- ဖွင့်ထားတဲ့ connection တွေ မပြတ်စေဘဲ config အသစ်ကို အသုံးချပါတယ်။
Config က ဒီမှာပါ — နာမည်တစ်ခုချင်းအတွက် server block တစ်ခုစီ —
# nginx/guestbook.conf# /etc/nginx/sites-available/guestbook# The website: example.com and www.example.com -> Next.js on port 3000server { listen 80; listen [::]:80; server_name example.com www.example.com; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }} # The API: api.example.com -> Express on port 4000server { listen 80; listen [::]:80; server_name api.example.com; client_max_body_size 1m; location / { proxy_pass http://127.0.0.1:4000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}တစ်ကြောင်းချင်း ရှင်းလင်းချက်
- ဒီ block က website နာမည်တွေအတွက် request တွေကို ဖြေပါတယ်။
- Compose ဖွင့်ပေးထားတဲ့ loopback port ကနေတဆင့် Next.js container ဆီ လွှဲပေးပါတယ်။
- App က ကိုယ့် domain ကို သိအောင် visitor ရိုက်ထည့်တဲ့ နာမည်ကို ဆက်ပို့ပေးပါတယ်။
- Visitor က https သုံးလား မသုံးလား app ကို ပြောပြပါတယ်။
trust proxyကြောင့် Express က ဒါကို ဖတ်ပါတယ်။ - နောက်ပိုင်း live feature တွေ ထည့်ရင် WebSocket connection တွေ ဖြတ်သန်းနိုင်အောင်ပါ။
- Server တစ်လုံးတည်းပေါ်က ဒုတိယနာမည်က Express ဆီ သွားပါတယ်။
- Node.js ဆီ မရောက်ခင် 1 MB ထက်ကြီးတဲ့ request body တွေကို ငြင်းပယ်ပါတယ်။
Browser မှာ http://example.com ကို ဖွင့်ကြည့်ပါ။ Guestbook ကို မြင်ရပါလိမ့်မယ် — လောလောဆယ် သော့ခလောက် မပါသေးပါဘူး။ မြင်ရရင် DNS၊ security group၊ Nginx နဲ့ Docker အားလုံး အလုပ်လုပ်နေပါပြီ။ Site က မလုံခြုံသေးလို့ နောက်တစ်ဆင့်မှာ ပြင်ပါမယ်။
အဆင့် ၈ — Let's Encrypt နဲ့ အခမဲ့ SSL
HTTPS က visitor နဲ့ ကိုယ့် server ကြားက အရာအားလုံးကို encrypt လုပ်ပေးပြီး ဒါမပါရင် browser တွေက “Not secure” လို့ ပြပါတယ်။ Let's Encrypt က certificate တွေကို အခမဲ့ ပေးပြီး Certbot က certificate ယူပေးကာ Nginx config ကိုပါ ပြင်ပေးပါတယ်။ Certbot site က အကြံပြုထားသလို snap နဲ့ install လုပ်ပါ။ Server ပေါ်မှာ —
# on the serversudo snap install --classic certbotsudo ln -s /snap/bin/certbot /usr/local/bin/certbotsudo certbot --nginx -d example.com -d www.example.com -d api.example.comsudo certbot renew --dry-runsystemctl list-timers | grep certbotတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Snap က Certbot ကို သူ့ဘာသာ update လုပ်ပေးပါတယ်။
- နာမည်သုံးခုလုံး ပါတဲ့ certificate တစ်ခု တောင်းပါတယ်။ Certbot က port 80 ကနေ ကိုယ်ပိုင်ကြောင်း သက်သေပြပြီး Nginx config ထဲကို
listen 443 sslနဲ့ certificate path တွေ ထည့်ပေးပါတယ်။ - ဘာမှ မပြောင်းဘဲ renewal ကို အစမ်းလုပ်ကြည့်ပါတယ်။ Certificate တွေက ရက် ၉၀ ခံပါတယ်။
- Certbot က certificate မကုန်ခင် အလိုအလျောက် renew လုပ်ပေးတဲ့ timer တစ်ခု install လုပ်ပေးပါတယ်။ ကိုယ်ပိုင် cron job မလိုပါဘူး။
Certbot က email (သက်တမ်းကုန်ခါနီး သတိပေးဖို့) နဲ့ HTTP ကို HTTPS ဆီ redirect လုပ်မလားလို့ မေးပါလိမ့်မယ်။ Redirect ကို ရွေးပါ။ https://example.com ကို reload လုပ်ကြည့်ပါ — သော့ခလောက် ပေါ်လာပြီး http:// က https:// ဆီ သူ့ဘာသာ ခုန်သွားပါပြီ။
အဆင့် ၉ — အလိုအလျောက် update၊ path A — server ပေါ်မှာ build လုပ်ခြင်း
လက်နဲ့ update လုပ်ရင် အကြိမ်တိုင်း SSH၊ git pull၊ ပြန် build လုပ်ရပါတယ်။ GitHub Actions က ဒါကို လုပ်ပေးနိုင်ပါတယ် — main ကို push လုပ်တိုင်း GitHub runner တစ်ခုက SSH နဲ့ ကိုယ့် server ထဲ login ဝင်ပြီး command တွေ အတူတူကို run ပါတယ်။ ဒီပထမ path က အရိုးရှင်းဆုံးပါ — image အသစ်တွေကို server ကိုယ်တိုင် build လုပ်ပါတယ်။
Server ထဲ login ဝင်နိုင်တဲ့ key တစ်ခု GitHub မှာ လိုပါတယ်။ ကိုယ့်ကွန်ပျူတာပေါ်မှာ deploy အတွက်သီးသန့် key pair တစ်ခု ဖန်တီးပြီး public တစ်ခြမ်းကို server ကို ပေးပါ —
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/guestbook_deploy -N ""ssh-copy-id -i ~/.ssh/guestbook_deploy.pub guestbookcat ~/.ssh/guestbook_deployတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- GitHub Actions တစ်ခုတည်းက သုံးမယ့် key အသစ်ပါ။ နောက်မှ ဖျက်လိုက်ရင်တောင် ကိုယ်ကိုယ်တိုင် ဝင်လို့မရ ဖြစ်မသွားပါဘူး။
- ရှိပြီးသား
guestbookshortcut ကို သုံးပြီး server ပေါ်က ~/.ssh/authorized_keys ထဲ public key ကို ထည့်ပေးပါတယ်။ - Private key ကို print ထုတ်ပါတယ်။ BEGIN နဲ့ END စာကြောင်းတွေ အပါအဝင် အားလုံးကို copy ကူးပါ။
ကိုယ့် repository မှာ Settings → Secrets and variables → Actions → New repository secret ကို ဖွင့်ပြီး secret သုံးခု ထည့်ပါ — SERVER_HOST (ကိုယ့် Elastic IP)၊ SERVER_USER (ubuntu) နဲ့ SERVER_SSH_KEY (ခုနက copy ကူးခဲ့တဲ့ private key)။ ပြီးရင် workflow ကို ထည့်ပါ —
# .github/workflows/deploy-ssh.ymlname: Deploy (build on the server) on: push: branches: [main] workflow_dispatch: concurrency: group: deploy cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest steps: - name: Pull and rebuild on EC2 uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} command_timeout: 30m script: | set -e cd ~/guestbook git fetch origin main git reset --hard origin/main docker compose up -d --build docker image prune -f docker compose psတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- main ကို push လုပ်တိုင်း run ပါတယ်။
- လက်နဲ့ ပြန် deploy လုပ်ဖို့ Actions tab မှာ “Run workflow” ခလုတ်လည်း ထည့်ပေးပါတယ်။
- နှစ်ကြိမ် မြန်မြန် push လုပ်ရင် တစ်ပြိုင်နက် build မလုပ်ဘဲ ဒုတိယ deploy က ပထမတစ်ခုကို စောင့်ပါတယ်။
- SSH session ဖွင့်ပြီး script run ပေးတဲ့ လူသုံးများတဲ့ action ပါ။
- Secret တွေကို GitHub က encrypt လုပ်ထားပြီး log ထဲမှာ ဖုံးထားပါတယ်။
- Server အသေးပေါ်မှာ Next.js build က မိနစ်အနည်းငယ် ကြာနိုင်ပါတယ် — default ၁၀ မိနစ် ကန့်သတ်ချက်က တိုလွန်းနိုင်ပါတယ်။
- ပထမဆုံး fail ဖြစ်တဲ့ command မှာ ရပ်ပါတယ် — build ပျက်ရင် app ကို ဘယ်တော့မှ restart မလုပ်ပါဘူး။
- Server ပေါ်မှာ တစ်ယောက်ယောက် file တွေ ပြင်ထားရင်တောင် server ရဲ့ copy ကို GitHub နဲ့ အတိအကျ တူအောင် လုပ်ပါတယ်။
- ပြောင်းထားတဲ့ image တွေကို ပြန် build ပြီး အဲဒီ container တွေကိုပဲ အစားထိုးပါတယ်။ Note volume က ကျန်ရစ်ပါတယ်။
- မသုံးတော့တဲ့ image အဟောင်းတွေကို ဖျက်ပါတယ် — မဟုတ်ရင် deploy အကြိမ် ဆယ်ချီပြီးရင် disk ပြည့်သွားပါလိမ့်မယ်။
Commit လုပ်၊ push လုပ်ပြီး run နေတာကို ကြည့်ဖို့ Actions tab ကို ဖွင့်ပါ။ အခုကစပြီး git push က ကိုယ့် deploy ခလုတ်ပါပဲ။
အဆင့် ၁၀ — Path B၊ အဆင့်မြှင့်ခြင်း — Actions မှာ build၊ server မှာ pull
Path A မှာ အားနည်းချက် ရှိပါတယ် — အလေးဆုံးအလုပ်ဖြစ်တဲ့ build ကို server က လုပ်ရပါတယ်။ Instance အသေးပေါ်မှာ deploy တိုင်း site က မိနစ်အနည်းငယ် နှေးသွားနိုင်ပြီး build ကြီးရင် memory ကုန်သွားနိုင်ပါတယ်။ Path B က build ကို GitHub ရဲ့ runner တွေ (memory 7 GB နဲ့ အထက်) ဆီ ရွှေ့ပြီး ပြီးသွားတဲ့ image တွေကို GitHub Container Registry (GHCR) ဆီ push လုပ်ပါတယ်။ Server က download လုပ်ပြီး container တွေကို စက္ကန့်ပိုင်းအတွင်း လဲလှယ်ရုံပါပဲ။
ပထမဆုံး image တွေကို build မလုပ်ဘဲ သုံးတဲ့ ဒုတိယ Compose file တစ်ခု —
# docker-compose.prod.ymlservices: api: image: ghcr.io/your-github-user/guestbook-api:latest environment: PORT: 4000 DATA_DIR: /app/data CORS_ORIGIN: https://example.com,https://www.example.com volumes: - notes-data:/app/data ports: - "127.0.0.1:4000:4000" healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:4000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 30s timeout: 5s retries: 3 restart: unless-stopped web: image: ghcr.io/your-github-user/guestbook-web:latest environment: API_URL: http://api:4000 ports: - "127.0.0.1:3000:3000" depends_on: api: condition: service_healthy restart: unless-stopped volumes: notes-data:တစ်ကြောင်းချင်း ရှင်းလင်းချက်
build:အစား GHCR ကနေ အသင့်ဖြစ်ပြီးသား image ကို pull လုပ်ပါတယ်။ နာမည်က စာလုံးအသေး သက်သက် ဖြစ်ရပါမယ်။- Web image ထဲမှာ GitHub ပေါ်မှာ build လုပ်တုန်းက သတ်မှတ်ထားတဲ့ NEXT_PUBLIC_API_URL ပါပြီးသားပါ။
ပြီးရင် job နှစ်ခုပါတဲ့ workflow — build (image နှစ်ခုကို အပြိုင်) နဲ့ deploy (build နှစ်ခုလုံး အောင်မှ) —
# .github/workflows/deploy-ghcr.ymlname: Deploy (build in Actions, pull on the server) on: push: branches: [main] workflow_dispatch: concurrency: group: deploy cancel-in-progress: false env: REGISTRY: ghcr.io OWNER: your-github-user jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write strategy: matrix: service: [api, web] steps: - uses: actions/checkout@v7 - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v7 with: context: ./${{ matrix.service }} push: true build-args: | NEXT_PUBLIC_API_URL=https://api.example.com tags: | ${{ env.REGISTRY }}/${{ env.OWNER }}/guestbook-${{ matrix.service }}:latest ${{ env.REGISTRY }}/${{ env.OWNER }}/guestbook-${{ matrix.service }}:${{ github.sha }} cache-from: type=gha,scope=${{ matrix.service }} cache-to: type=gha,mode=max,scope=${{ matrix.service }} deploy: needs: build runs-on: ubuntu-latest steps: - name: Pull the new images on EC2 uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} script: | set -e cd ~/guestbook git fetch origin main git reset --hard origin/main docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d docker image prune -f docker compose -f docker-compose.prod.yml psတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Built-in GITHUB_TOKEN ကို သုံးပြီး GHCR ဆီ image push ခွင့် ပေးပါတယ်။ Secret အပို မလိုပါဘူး။
- အဆင့်တွေ အတူတူကို အပြိုင် နှစ်ကြိမ် run ပါတယ် — api အတွက် တစ်ကြိမ်၊ web အတွက် တစ်ကြိမ်။
- Cache ကို support လုပ်တဲ့ Docker ရဲ့ ခေတ်မီ builder ဖြစ်တဲ့ BuildKit ကို setup လုပ်ပါတယ်။
- Run ကို စတင်ခဲ့တဲ့ user အနေနဲ့ ghcr.io ကို login ဝင်ပါတယ်။
- Matrix entry ပေါ်မူတည်ပြီး api ဒါမှမဟုတ် web folder ကနေ build လုပ်ပါတယ်။
- Public API လိပ်စာကို web build ထဲ ထည့်ပါတယ်။ API image ကတော့ လျစ်လျူရှုပါတယ်။
- Tag နှစ်ခု — server က pull လုပ်မယ့်
latest… - …နဲ့ commit id — အရင် build တစ်ခုခုကို ပြန်သွားလို့ရအောင်ပါ။
- GitHub cache ထဲမှာ သိမ်းထားတဲ့ နောက်ဆုံး run ရဲ့ layer တွေကို ပြန်သုံးလို့ မပြောင်းတဲ့ အဆင့်တွေကို ကျော်သွားပါတယ်။
- Image နှစ်ခုလုံး build ပြီး push အောင်မှပဲ deploy လုပ်ပါတယ်။
- Server ပေါ်က Compose file ကိုယ်တိုင်ကို update လုပ်ပါတယ်။
- Image အသစ်တွေကို download လုပ်ပါတယ်။ ပြောင်းထားတဲ့ layer တွေပဲ လွှဲပြောင်းပါတယ်။
- Image ပြောင်းသွားတဲ့ container တွေကိုပဲ ပြန်ဖန်တီးပါတယ်။
GHCR package အသစ်တွေက default အနေနဲ့ private ဖြစ်လို့ pull လုပ်ဖို့ server က တစ်ကြိမ် login ဝင်ရပါမယ်။ GitHub မှာ read:packages scope တစ်ခုတည်းပါတဲ့ classic personal access token တစ်ခု ဖန်တီးပြီး server ပေါ်မှာ —
# on the server (once)echo YOUR_TOKEN | docker login ghcr.io -u your-github-user --password-stdinတစ်ကြောင်းချင်း ရှင်းလင်းချက်
- Token ကို stdin ကနေ ဖတ်လို့ shell history ထဲ မရောက်ပါဘူး။ Docker က login ကို ~/.docker/config.json ထဲမှာ သိမ်းပါတယ်။
Workflow နှစ်ခုလုံး push တိုင်း မ run အောင် ပြောင်းတဲ့အခါ deploy-ssh.yml ကို ဖျက်ပါ။ ဘယ် path ကို ရွေးသင့်လဲ?
- Build ဘယ်မှာ run လဲ: A — ကိုယ့် server ပေါ်မှာ။ B — GitHub ရဲ့ runner တွေပေါ်မှာ။
- လိုအပ်တဲ့ server အရွယ်: A — memory 2 GB၊ ဒါမှမဟုတ် 1 GB နဲ့ swap။ B — 1 GB နဲ့ ရပါတယ်။
- Deploy လုပ်နေချိန် site: A — CPU က build လုပ်နေတုန်း မိနစ်အနည်းငယ် နှေးတယ်။ B — container လဲလှယ်တဲ့ စက္ကန့်အနည်းငယ်ပဲ။
- Setup: A — secret သုံးခုနဲ့ workflow တစ်ခု။ B — အဲဒါအပြင် ဒုတိယ Compose file နဲ့ registry login။
- Rollback: A — commit ကို revert လုပ်ပြီး rebuild ကို စောင့်ရတယ်။ B — server ကို
:shatag အဟောင်းဆီ ညွှန်ပြီး restart လုပ်ရုံပဲ။ - လေ့လာနေတုန်း A နဲ့ စပါ၊ deploy တွေ နှေးလာရင် ဒါမှမဟုတ် server memory ကုန်လာရင် B ကို ပြောင်းပါ။
တစ်ခုခု မှားသွားတဲ့အခါ
မြေပုံကို အပေါ်ကနေ လိုက်ကြည့်ပါ။ လူတိုင်းနီးပါး တစ်ကြိမ်တော့ ကြုံဖူးတဲ့ ပြဿနာတွေပါ —
- Browser က site ကို လုံးဝ ရှာမတွေ့ဘူး။ DNS က server ကို မညွှန်သေးပါဘူး။
dig +short example.com @8.8.8.8ကို run ပါ — IP မှားနေရင် GoDaddy record တွေကို ပြန်စစ်ပါ။ - Connection timeout ဖြစ်တယ်။ Security group မှာ port 80 ဒါမှမဟုတ် 443 မပါဘူး၊ ဒါမှမဟုတ် instance ရပ်နေတယ်။ မှတ်ထားပါ — Elastic IP က မပြောင်းပေမဲ့ ရိုးရိုး public IP က ပြောင်းပါတယ်။
- 502 Bad Gateway။ Nginx က ကောင်းနေပေမဲ့ နောက်ကွယ်မှာ ဘယ်သူမှ မဖြေပါဘူး။ Server ပေါ်မှာ
docker compose psနဲ့docker compose logs --tail=50 webကို run ပါ။ - Page ပွင့်ပေမဲ့ browser console မှာ CORS error နဲ့ note တွေ fail ဖြစ်တယ်။
CORS_ORIGINက browser bar ထဲက လိပ်စာနဲ့ အတိအကျ မတူပါဘူး (https နဲ့ http၊ www နဲ့ domain သက်သက်)။ - Server page က ဗလာဖြစ်နေတယ် ဒါမှမဟုတ် ECONNREFUSED error ပြတယ်။
API_URLကlocalhostလို့ ရေးထားပါတယ်။ Container ထဲမှာ အဲဒါက container ကိုယ်တိုင်ပါ — service နာမည်http://api:4000ကို သုံးပါ။ - Build က “Killed” ဒါမှမဟုတ် exit code 137 နဲ့ ရပ်သွားတယ်။ Memory ကုန်သွားတာပါ။ Swap ထည့်ပါ (အဆင့် ၃)၊ instance ပိုကြီးကြီး သုံးပါ၊ ဒါမှမဟုတ် path B ကို ပြောင်းပါ။
- Form က API လိပ်စာအဟောင်းဆီ post လုပ်နေတယ်။
NEXT_PUBLIC_API_URLကို build အချိန်မှာ သတ်မှတ်ပါတယ်။ Build argument ကို ပြောင်းပြီး ပြန် build လုပ်ပါ — restart လုပ်ရုံနဲ့ မလုံလောက်ပါဘူး။ - “No space left on device”။ Image အဟောင်းတွေကြောင့်ပါ။
docker system dfကို run ပြီးရင်docker image prune -aကို run ပါ (ဘယ် container မှ မသုံးတဲ့ image အားလုံးကို ဖျက်ပါတယ်)။ - Deploy job က “ssh: handshake failed” နဲ့ fail ဖြစ်တယ်။
SERVER_SSH_KEYမှားနေတယ် (private key တစ်ခုလုံးကို copy ကူးပါ)၊ ဒါမှမဟုတ် GitHub ရဲ့ IP တွေအတွက် port 22 ပိတ်နေတယ်။
Server ပေါ်မှာ မေးခွန်းအများစုကို command သုံးခုက ဖြေပေးပါတယ် — docker compose logs -f၊ sudo tail -f /var/log/nginx/error.log နဲ့ curl -I http://127.0.0.1:3000။
ဒီ website ဘယ်လို run နေလဲ
ဒီ site၊ winechanz.me က ဒီနည်းအတိုင်း အတိအကျ သုံးထားပြီး service နှစ်ခုအစား သုံးခု ရှိပါတယ် —
- Elastic IP၊ Ubuntu၊ Docker Engine နဲ့ host ပေါ်မှာ Nginx ပါတဲ့ EC2 server တစ်လုံး။
- Compose file တစ်ခုထဲက container သုံးခု — public site (Next.js) ကို 3000၊ admin panel (Next.js) ကို 3001 နဲ့ API (Express + MongoDB Atlas) ကို 4000 မှာ၊ အားလုံးကို 127.0.0.1 ပေါ်မှာပဲ ဖွင့်ထားပါတယ်။
- Domain host မှာ A record တစ်ခုစီရှိတဲ့ နာမည် သုံးခု — domain သက်သက်၊
admin.နဲ့api.၊ အားလုံး Let's Encrypt setup တစ်ခုတည်းပေါ်မှာ။ - Deploy အတွက် Path A —
prodbranch ကို push လုပ်ရင် GitHub Actions job တစ်ခုက SSH နဲ့ ဝင်ပြီး branch အတိုင်း reset လုပ်ကာdocker compose up -d --buildကို run ပါတယ်။ - ခက်ခက်ခဲခဲ သင်ခဲ့ရတဲ့ သင်ခန်းစာ တစ်ခု — API က ကောင်းကောင်း ဖြေနေပေမဲ့ server အသစ်ပေါ်မှာ journal entry တွေ မပေါ်ခဲ့ပါဘူး။ Site ရဲ့
API_URLကို local.envကနေhttp://localhost:4000အဖြစ် copy ကူးထားခဲ့လို့ပါ။ Compose file ထဲမှာhttp://api:4000လို့ သတ်မှတ်လိုက်တော့ ပြေလည်သွားပါတယ်။
Launch checklist
- App က ကိုယ့်ကွန်ပျူတာပေါ်မှာ
docker compose upနဲ့ run တယ်။ - AWS Billing မှာ budget alert ရှိတယ်။
- EC2 instance က 20 GB disk နဲ့ Elastic IP ပါတဲ့ Ubuntu 24.04 ကို run နေတယ်။
- Security group က 80 နဲ့ 443 ကို ဘယ်နေရာကမဆို ခွင့်ပြုပြီး 22 ကို ကိုယ်ရည်ရွယ်ထားသလောက်ပဲ ခွင့်ပြုတယ်။
- Swap ဖွင့်ထားပြီး
docker compose versionက sudo မပါဘဲ အလုပ်လုပ်တယ်။ dig +shortက domain၊wwwနဲ့apiအတွက် ကိုယ့် Elastic IP ကို ပြန်ပေးတယ်။sudo nginx -tအောင်ပြီးhttp://example.comက app ကို ပြတယ်။- Certbot က certificate ထုတ်ပေးပြီး
renew --dry-runအောင်တယ်၊ HTTP က HTTPS ဆီ redirect ဖြစ်တယ်။ mainဆီ test commit တစ်ခုက server ကို မထိဘဲ live site ပေါ် ရောက်လာတယ်။
ဒါပါပဲ — localhost ကနေ သော့ခလောက်ပါပြီး သူ့ဘာသာ update ဖြစ်တဲ့ တကယ့်လိပ်စာ တစ်ခုအထိ ခရီးတစ်ခုလုံးပါ။ ဘယ်အစိတ်အပိုင်းမှ မှော်ပညာ မဟုတ်ပါဘူး — ငှားထားတဲ့ Linux ကွန်ပျူတာ၊ ဖုန်းစာအုပ် entry တစ်ခု၊ proxy တစ်ခု၊ certificate တစ်ခုနဲ့ push လုပ်တိုင်း run တဲ့ script တစ်ခုပါ။ တစ်ကြိမ် လုပ်ဖူးသွားရင် နောက် project က တစ်ပတ် မဟုတ်ဘဲ ညနေခင်း တစ်ခုပဲ ကြာပါတော့တယ်။

