On this page

Static Sites & nginx

Serving a built front-end (React, Vue, Angular, Astro, plain HTML) on Guara Cloud is a great fit — but the default nginx image does not run as-is here. This page explains why and gives you copy-paste recipes that work on the first try.

The short version: containers on Guara Cloud run as a non-root user (UID 1000) and listen on an unprivileged port. Any web server you use has to respect those two rules. Use nginxinc/nginx-unprivileged (Recipe 1) and you are done in a minute.

Why the stock nginx image struggles

The official nginx image is built to run as root: it binds port 80, and it pre-creates and writes to /var/cache/nginx, /var/log/nginx, and /var/run/nginx.pid. Guara Cloud forces the container to UID 1000 and grants only the narrow NET_BIND_SERVICE capability, so binding a configured low port is allowed but the image’s root-owned filesystem assumptions remain invalid. Guara Cloud also mounts group-writable volumes at /var/cache, /var/log, /var/run, and /tmp that shadow whatever the image baked in at those paths. Startup behavior varies by image version, but you may see errors such as the master process failing to open its log file, because /var/log/nginx no longer exists inside the shadowed, empty volume:

nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (2: No such file or directory)

You may also see the harmless-but-noisy warning:

nginx: [warn] the "user" directive makes sense only if the master process runs with super-user privileges, ignored

Guara Cloud does mount writable ephemeral volumes at /tmp, /var/run, /var/cache, and /var/log, but those mounts shadow the directory tree the stock image ships, and the default nginx.conf still points at paths and a port that assume root. The fix is to run an image (or config) that expects a non-root user — exactly what the recipes below do.

nginxinc/nginx-unprivileged is the official nginx image, pre-configured to run as a non-root user, listen on port 8080, and log to stdout/stderr. It needs no custom config.

FROM nginxinc/nginx-unprivileged:alpine

# Copy your build output (Vite/CRA: dist or build; Astro: dist)
COPY dist/ /usr/share/nginx/html

Create the service on port 8080 and make it public:

guara services create --port 8080 --public

That’s it. The image already listens on 8080, writes temp files under /tmp, and sends logs to stdout/stderr where guara logs can pick them up.

Recipe 2: stock nginx with a custom config

If you must use the stock nginx image, override nginx.conf so every writable path lives under /tmp and the server listens on 8080.

nginx.conf:

pid /tmp/nginx.pid;

events {}

http {
  # Redirect nginx's temp paths to a location UID 1000 can write to.
  client_body_temp_path /tmp/client_temp;
  proxy_temp_path       /tmp/proxy_temp;
  fastcgi_temp_path     /tmp/fastcgi_temp;
  uwsgi_temp_path       /tmp/uwsgi_temp;
  scgi_temp_path        /tmp/scgi_temp;

  # Log to the container's stdout/stderr, not to files.
  access_log /dev/stdout;
  error_log  /dev/stderr;

  include       /etc/nginx/mime.types;
  default_type  application/octet-stream;
  sendfile      on;

  server {
    listen 8080;
    root   /usr/share/nginx/html;
    index  index.html;

    # SPA fallback: serve index.html for client-side routes.
    location / {
      try_files $uri $uri/ /index.html;
    }
  }
}

Dockerfile:

FROM nginx:alpine

# Replace the root-oriented default config.
COPY nginx.conf /etc/nginx/nginx.conf
COPY dist/ /usr/share/nginx/html
guara services create --port 8080 --public

Note there is no top-level user directive: without it nginx runs the workers as the current user (UID 1000) and skips the privilege-drop warning.

Recipe 3: alternatives to nginx

Any server that listens on an unprivileged port as a non-root user works. A few lightweight options:

Caddy

Caddy serves static files without requiring root. Configure its listener explicitly; changing the Guara service target port does not change Caddy’s own listener.

Add a Caddyfile:

:8080 {
  root * /usr/share/caddy
  encode gzip
  try_files {path} /index.html
  file_server
}
FROM caddy:alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY dist/ /usr/share/caddy
guara services create --port 8080 --public

BusyBox httpd

Tiny (~5 MB) and non-root friendly:

FROM busybox:musl
COPY dist/ /var/www
EXPOSE 8080
CMD ["httpd", "-f", "-v", "-p", "8080", "-h", "/var/www"]
guara services create --port 8080 --public

Static Node servers (serve, http-server)

Handy if your build already produces a Node image:

FROM node:20-alpine
WORKDIR /app
RUN npm install -g serve
COPY dist/ ./dist
EXPOSE 8080
# -s enables SPA fallback to index.html
CMD ["serve", "-s", "dist", "-l", "8080"]
guara services create --port 8080 --public

Checklist

  • Server listens on an unprivileged port (8080 recommended) and matches --port.
  • No reliance on being root — prefer an image built for non-root, or redirect writes to /tmp.
  • Logs go to stdout/stderr so guara logs can read them.
  • Service created with --public so the site is reachable from the internet.
  • SPA? A try_files … /index.html fallback is in place for client-side routing.