← All posts
Kamal / Docker / Deployment

Your first Kamal deployment: from an HTML file to HTTPS

Deploy a single-page site to your own server with Nginx, Docker, GHCR, a custom domain, and Kamal.

A single HTML page is enough to learn how deployment works. Without a framework, database, or build tool, it is easier to see what each part does.

We will create a page, package it in Docker, and deploy it to our own server using Kamal. The result will be a site served over HTTPS, with one command to ship the next update.

How the pieces fit together

Nginx serves the HTML inside a container. GitHub Container Registry, or GHCR, stores the Docker image. Kamal connects to the server over SSH, delivers the image, and routes traffic to it through kamal-proxy.

Laptop → image build → GHCR → container on the server
                                     ↑
Browser → HTTPS → kamal-proxy ────────┘

The proxy terminates TLS. Traffic between the proxy and the container uses HTTP inside the Docker network, so our application’s Nginx listens on port 8080.

Prerequisites

Your computer needs Git, Ruby, Docker with Buildx, and GitHub CLI. Docker must be running for the local container check. This example uses Kamal 2.11.0, the same version used to verify this blog’s deployment configuration:

gem install kamal --version 2.11.0 --no-document
kamal version
docker version
docker buildx version

You also need a domain, a GitHub account, and a server. The example assumes a fresh Ubuntu 24.04 server with amd64 architecture and initial SSH access to root using a key.

Replace example.com with your domain, 203.0.113.10 with your server’s IP, and your-github-user with your lowercase GitHub username throughout. These are documentation placeholders, not deployment targets.

1. Create the page

Start with a project directory:

mkdir hello-kamal
cd hello-kamal
git init -b main
mkdir -p public config .kamal

Create public/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Hello, Kamal</title>
    <style>
      body {
        max-width: 640px;
        margin: 15vh auto;
        padding: 24px;
        background: #141615;
        color: #e7e8e3;
        font: 18px/1.6 system-ui, sans-serif;
      }
      h1 { color: #b8d99b; }
    </style>
  </head>
  <body>
    <h1>Hello, Kamal.</h1>
    <p>A single HTML file, running on my own server.</p>
  </body>
</html>

2. Package the site in Docker

Create config/nginx.conf:

server {
    listen 8080;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;
    absolute_redirect off;

    location = /up {
        access_log off;
        default_type text/plain;
        return 200 "ok\n";
    }

    location / {
        try_files $uri $uri/ =404;
    }
}

/up is the readiness endpoint: Kamal checks it before routing traffic to the container. absolute_redirect off keeps redirects relative, preventing the internal container port from appearing in redirects behind an HTTPS proxy. See the Nginx documentation.

Create a Dockerfile in the project root:

FROM nginxinc/nginx-unprivileged:stable-alpine
LABEL org.opencontainers.image.source="https://github.com/your-github-user/hello-kamal"
COPY config/nginx.conf /etc/nginx/conf.d/default.conf
COPY public/ /usr/share/nginx/html/
EXPOSE 8080

This image runs Nginx without root. The org.opencontainers.image.source label points to the source repository.

Add a .dockerignore:

.git
.kamal
.env
.env.*

And a .gitignore:

.env
.env.*
.DS_Store

Test the container locally:

docker build -t hello-kamal .
docker run --rm -p 8080:8080 hello-kamal

Open http://localhost:8080. In another terminal, run:

curl --fail http://localhost:8080/up

The response should be ok. Press Ctrl+C in the first terminal to stop the container.

3. Prepare the server and domain

If the server already has Docker and a deployment user with access to it, skip installation. On a fresh server, connect as root:

ssh root@203.0.113.10

Run the following commands on the server. They create the deploy user and copy the keys already authorized for root:

apt-get update
apt-get install -y docker.io curl
systemctl enable --now docker

adduser --disabled-password --gecos "" deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
usermod -aG docker deploy
exit

Docker access effectively grants administrative control of the server, so use your own trusted key. Open a new SSH connection after changing group membership. Verify from your computer:

ssh deploy@203.0.113.10 'docker version --format "{{.Server.Version}}"'

At your DNS provider, add an A record for @ → 203.0.113.10. Remove conflicting parking or URL Redirect records for the root domain. An existing AAAA record must point to this server’s working IPv6 address; otherwise remove it. Allow ports 22, 80, and 443 in the server firewall and the hosting provider’s network rules.

Before deployment, check the address:

dig +short A example.com

It should match your server’s IP. Automatic HTTPS requires the domain to resolve to the server and port 443 to be reachable. See Kamal’s proxy configuration.

4. Connect GHCR

Run the remaining commands from the project directory on your computer. Sign in to GitHub CLI and request permission to publish images:

gh auth login --hostname github.com
gh auth refresh --hostname github.com --scopes write:packages
gh auth token --hostname github.com | docker login ghcr.io --username your-github-user --password-stdin

Complete authorization in your browser. The final command pipes the token directly to Docker without printing it.

Put only the token lookup command in .kamal/secrets:

KAMAL_REGISTRY_PASSWORD=$(gh auth token --hostname github.com)

This file can be committed because it contains no token. Do not replace the command with a literal token in a commit. Kamal supports command substitution for secret values; see its environment variable documentation.

For a separate GHCR token, use a classic Personal Access Token with the required package permissions. GitHub Actions can use its built-in GITHUB_TOKEN instead of a personal token. The options are covered in GitHub’s GHCR guide.

5. Describe the deployment

Create config/deploy.yml:

service: hello-kamal
image: your-github-user/hello-kamal

servers:
  web:
    - 203.0.113.10

proxy:
  ssl: true
  host: example.com
  app_port: 8080
  healthcheck:
    path: /up

registry:
  server: ghcr.io
  username: your-github-user
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

ssh:
  user: deploy

app_port must match Nginx’s listen port. image names the image inside the registry; service identifies the application to Kamal. For an ARM server, change amd64 to arm64.

This configuration builds the image on your computer. If the Docker daemon is only available on your server, replace the builder block with:

builder:
  arch: amd64
  remote: ssh://deploy@203.0.113.10
  local: false

Remote builds still require Docker CLI and Buildx locally. See the Kamal builder documentation for the options.

6. Deploy for the first time

Commit the project and create a GitHub repository:

git add .
git commit -m "Add a single-page site and Kamal deployment"
gh repo create hello-kamal --private --source=. --remote=origin --push

Kamal builds from a local Git checkout by default, so uncommitted changes will not be included in the image.

For the application’s first deployment to the prepared server, run:

kamal setup

If Docker and a compatible kamal-proxy are already running for another application, you can use kamal deploy directly. Avoid starting a second reverse proxy on the same ports.

Once deployment completes, verify the site:

curl --fail --location --head https://example.com

Your browser should display the page over HTTPS. kamal-proxy manages the certificate; this example does not need a separate Certbot installation.

7. Ship updates and roll back

Change the text in public/index.html, then run:

git add public/index.html
git commit -m "Update homepage copy"
git push
kamal deploy

Kamal delivers the new image and checks /up before switching traffic. To inspect logs and retained containers:

kamal app logs
kamal app containers -q

To return to an earlier release, take its version from the old container’s name and run kamal rollback VERSION, replacing VERSION with the actual identifier. The previous container or image must still be available. See the rollback guide.

If the site does not open

First separate DNS resolution from server availability. Compare a regular request with one pinned to the correct IP:

curl --head --location --max-time 15 https://example.com
curl --head --location --max-time 15 --resolve example.com:443:203.0.113.10 https://example.com

If only the second request works, check the IP returned by DNS. After an A record changes, a router or upstream resolver may retain the old address until its TTL expires. Clearing browser cache does not refresh a router’s DNS cache.

If the pinned request also fails, inspect application logs, exposed ports, and domain configuration. Keep certificate verification enabled: adding -k can hide a TLS problem.

You can now publish an update with a commit and kamal deploy. The next step is to move deployment into GitHub Actions and run it after successful checks when a pull request merges into main.