A modern web interface for managing certificates with Smallstep Step-CA.
This user interface would not be possible without the effort by @marcin-kruszynski in the first place. The enhancements made to the original step-ui were largely the work of Claude AI.
Any use of step-ui should be confined to personal, non-mission critical applications such as a homelab. While additional security hardening has been incorporated, the security posture of this product should be considered unsuitable for production use in a business/enterprise environment.
- Issue certificates with custom SANs
- Sign CSRs
- Certificate inventory management
- Download certificates in various formats (PEM, PFX)
- Audit logging
- Modern, responsive UI
- Docker and Docker Compose
- A running Step-CA instance
- Step-CA provisioner credentials
The CA root fingerprint is required for secure communication with Step-CA. You can obtain it in several ways:
Option A: From the Step-CA server directly
# SSH into your Step-CA server and run:
step certificate fingerprint $(step path)/certs/root_ca.crtOption B: From the Step-CA logs When Step-CA starts, it prints the root fingerprint in the logs. Look for a line like:
Root fingerprint: 1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7a8b9c0d1e2f
Option C: Using curl and openssl
# Download the root certificate
curl -sk https://your-ca-url:9000/root > /tmp/root_ca.crt
# Calculate the fingerprint
openssl x509 -in /tmp/root_ca.crt -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':'Create a .env file in the project root with your Step-CA configuration:
# Step-CA Configuration
CA_URL=https://ca.home:9000
CA_ROOT_FINGERPRINT=your_root_fingerprint_here
PROVISIONER_NAME=ui-admin
PROVISIONER_PASSWORD=your_provisioner_password
# Optional but recommended -- see Security Notes below
API_AUTH_TOKEN=
NEXT_PUBLIC_API_AUTH_TOKEN=
# Creates the first admin account -- see "User Accounts & Roles" below
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=Important: Replace the values with your actual Step-CA details:
CA_URL: Your Step-CA URLCA_ROOT_FINGERPRINT: The fingerprint you obtained in step 1PROVISIONER_NAME: Your provisioner namePROVISIONER_PASSWORD: Your provisioner passwordAPI_AUTH_TOKEN/NEXT_PUBLIC_API_AUTH_TOKEN: set both to the same value (e.g. output ofopenssl rand -hex 32) to require a bearer token on the API; see Security NotesINITIAL_ADMIN_USERNAME/INITIAL_ADMIN_PASSWORD: creates the first login account on first boot; see "User Accounts & Roles" below
NEXT_PUBLIC_API_URL (in docker-compose.yml/.env) controls the backend URL the browser talks to. It behaves like a normal runtime environment variable: change it and recreate the frontend container (docker compose up -d, or Dockhand's "save and redeploy"/"force recreate") -- no image rebuild needed. Under the hood the value still gets substituted into the compiled frontend at container start (frontend/docker-entrypoint.sh), because Next.js normally bakes NEXT_PUBLIC_* variables into the JS bundle at build time and would otherwise ignore anything set afterwards.
Make sure NEXT_PUBLIC_API_URL is actually set in your .env file (or Dockhand's own env var UI) -- docker-compose.yml references it as plain ${NEXT_PUBLIC_API_URL} with no compose-level default, since some Docker management UIs (Dockhand included) don't support the ${VAR:-default} bash-style fallback syntax in their own variable fields. If it's ever left unset, the entrypoint script fails safe rather than breaking the container: it logs a warning and leaves the frontend serving its build-time placeholder instead of guessing at a value.
If you haven't created a provisioner for the UI yet, run this on your Step-CA server:
step ca provisioner add ui-admin --type=JWK --createSave the password that's generated - you'll need it for the PROVISIONER_PASSWORD in your .env file.
- Clone this repository
- Configure your
.envfile (see Configuration section above) - Start the services:
./setup.shOr manually:
docker compose build
docker compose up -d- Access the web interface at
http://localhost:3000
step-ui requires signing in. There's no self-registration -- accounts are created and managed by an admin from the Settings page.
First login: on first boot, if no accounts exist yet, the backend
creates one admin account from INITIAL_ADMIN_USERNAME /
INITIAL_ADMIN_PASSWORD in your .env. This only ever happens once --
those two variables are checked only when the accounts table is completely
empty, and are ignored on every start after that (safe to leave set, blank
them out, or change them; none of it matters once an account exists). If
the table is empty and they're unset, the backend logs a startup warning
and nobody can log in until you set them and restart.
Roles:
- admin -- full access: issue, sign, renew, and revoke certificates, view CA settings, and manage user accounts (Settings → User Accounts).
- viewer -- read-only: can view the certificate inventory and CA settings, but not issue/sign/renew/revoke, and can't manage users. The nav bar hides links to pages a viewer can't use, but that's just UX -- the backend independently rejects those requests for a viewer account regardless of what the frontend shows.
Sessions: logging in sets a session cookie good for 3 days, with no idle timeout (it's a flat expiry from login, not extended by activity). Logging out, changing a user's password, or deleting a user account all immediately invalidate any session tied to that account -- there's nothing client-side to expire, the session is just gone from the backend's database. The last remaining admin account can't be demoted to viewer or deleted, so there's no way to lock yourself out entirely once at least one admin exists.
This layers on top of API_AUTH_TOKEN (see Security Notes below), it
doesn't replace it -- that token is a separate container-to-container
shared secret between the frontend and backend, unrelated to user logins.
Both images are tagged with a build identity so you can always tell exactly what's running -- useful once you have several local builds, stacks, or Dockhand/Portainer instances in play at once and want to be sure the image you're looking at is the one you think it is.
VERSIONat the repo root is the single source of truth for a human-meaningful build number. Bump it yourself whenever you want to mark a new build (a plain integer, like a firmware revision counter, is enough -- it doesn't need to follow any particular scheme)../build.shbuilds both images viadocker compose --progress=plain build --no-cache, readingVERSIONplus the current git commit and build timestamp, and passing all three through to both Dockerfiles as build args. It also createsdata/andlogs/if they don't exist yet, and saves the full unfolded build log to a timestamped file underlogs/so nothing scrolls by unseen.- Both Dockerfiles print the version/commit/date early in the build (visible in the log) and bake them into the image as standard OCI labels (
org.opencontainers.image.version,.revision,.created). Docker UIs that read image labels -- Portainer confirmed, likely Dockhand too -- will surface these directly; otherwisedocker inspect <container-or-image> --format '{{json .Config.Labels}}'always shows the truth regardless of what tag the image happens to be under. - The same identity is also visible right in the web UI, no
docker inspectneeded: every page's footer shows the version number (e.g.step-ui v1), and the Settings page has a "System Information" section with the full detail for both images -- version, commit, build date, plus the Node.js/Next.js versions the frontend is running and the Go version the backend is running. The backend also serves this itself atGET /api/version.
If you build with plain docker compose build instead of ./build.sh, these all default to unknown rather than silently showing stale or misleading info.
- Navigate to "Issue Certificate" in the navigation menu
- Enter the Common Name (CN) and any Subject Alternative Names (SANs)
- Set the validity period
- Choose the download format (PEM or PFX)
- Click "Issue Certificate"
- Download the certificate bundle
- Navigate to "Sign CSR" in the navigation menu
- Paste your Certificate Signing Request (CSR) in PEM format
- Set the validity period
- Click "Sign CSR"
- Download the signed certificate
- Navigate to "Inventory" to see all issued certificates
- View certificate details, status, and expiration dates
- Filter by status (active, expired, expiring soon)
This error means the CA_ROOT_FINGERPRINT is missing or incorrect in your .env file. Follow the configuration steps above to obtain and set the correct fingerprint.
- Verify your Step-CA is running and accessible at the configured
CA_URL - Check that the CA_URL is correct in your
.envfile - Ensure there are no firewall rules blocking access
- Verify your provisioner name and password are correct
- Ensure the provisioner exists on your Step-CA server
- Check the provisioner hasn't been disabled
If a plain restart or redeploy doesn't pick up a new NEXT_PUBLIC_API_URL, the frontend image predates the runtime-substitution fix described above and still bakes the value in at build time. Rebuild the frontend image once (docker compose build frontend, or Dockhand's "build images" rather than "save and redeploy" -- the latter recreates the container from the existing image without rerunning the build, so it can't pick up a value that's compiled into the JS). After that one rebuild, changing NEXT_PUBLIC_API_URL and recreating the container is enough going forward.
cd backend
go run ./cmdcd frontend
npm install
npm run dev- Frontend: Next.js 16 with TypeScript, TailwindCSS
- Backend: Go with Gin framework
- Database: SQLite for certificate metadata and audit logs
- CA Integration: Smallstep Step-CA via CLI
Both containers serve plain HTTP to each other and to whatever's mounted -- there's no TLS code in step-ui itself, deliberately. Certificate storage, permissions, and renewal are a well-solved problem one layer up; a reverse proxy in front of the stack handles it far more robustly than anything hand-rolled here would, and it's the standard pattern for exactly this kind of setup. Terminate TLS at the proxy and step-ui never needs to know about certificates at all.
Once you put a reverse proxy in front, two settings need to point at the proxy's HTTPS origin instead of the containers' plain-HTTP ones:
NEXT_PUBLIC_API_URL-- the URL the browser calls directly, e.g.https://step-ui-api.home.arpaCORS_ALLOWED_ORIGIN-- the frontend's own origin as the browser now sees it, e.g.https://step-ui.home.arpa
Any reverse proxy works (Caddy, Traefik, NGINX Proxy Manager, Synology's built-in one, etc.) -- point it at the frontend and backend containers' plain ports (3000 and 8080) and give it whatever certificate you want the browser to see. If you're already running step-ca for other things, Caddy is worth a look specifically: it can request and renew real certificates from your own step-ca automatically over ACME, instead of a public CA -- no self-signed browser warnings, and no paid wildcard needed. That does mean adding an ACME provisioner to step-ca (separate from the JWK provisioner step-ui itself uses): step ca provisioner add acme --type ACME on the CA host.
Since step-ca's own ACME endpoint is itself serving a certificate signed by your CA (not a publicly trusted one), Caddy also needs to be told to trust your CA's root when it calls that endpoint -- via trusted_roots, pointed at your CA's root certificate (step ca root prints it, or it's at $(step path)/certs/root_ca.crt on the CA host). A minimal example:
step-ui.example.com {
tls {
issuer acme https://ca.example.com:9000/acme/acme/directory {
trusted_roots /etc/caddy/ca-root.pem
}
}
reverse_proxy step-ui-frontend:3000
}
step-ui-api.example.com {
tls {
issuer acme https://ca.example.com:9000/acme/acme/directory {
trusted_roots /etc/caddy/ca-root.pem
}
}
reverse_proxy step-ui-backend:8080
}
(acme/acme in the URL is /acme/<provisioner name>/directory -- adjust to match your own provisioner's name. /etc/caddy/ca-root.pem is wherever you mount the CA's root cert into the Caddy container -- e.g. bind-mounting it read-only straight from the CA host's filesystem if Caddy runs there too. If Caddy runs in Docker, put it on the same network as the step-ui containers so it can reach them by service name, as above; otherwise reverse-proxy to the host's published 3000/8080 instead. Both hostnames need to actually resolve, internally, to wherever Caddy is listening -- step-ca has to be able to reach Caddy at that name to complete the ACME challenge, same as any ACME issuance.)
If Caddy runs on the same host as step-ca itself, port 443 is likely already taken by step-ca -- move Caddy's own HTTPS listener to another port (append it to the site address, e.g. step-ui.example.com:8443 { ... }, browsers then need that port in the URL too) and add disable_tlsalpn_challenge inside the issuer acme block, since the TLS-ALPN-01 challenge is hardcoded by the ACME spec to port 443 and would otherwise collide. That leaves HTTP-01 (port 80) as the challenge method, which is fine as long as step-ca isn't also using port 80.
NGINX Proxy Manager and most GUI reverse proxies don't support pointing at a custom/private ACME server -- only Let's Encrypt or a manually uploaded certificate -- so with those you're back to bring-your-own-cert with manual renewal, same as if step-ui handled it directly. Fine if you already have a wildcard cert you're managing that way regardless.
- Keep your
.envfile secure and never commit it to version control - The provisioner password is sensitive - protect it appropriately
- Consider using Docker secrets for production deployments
- Set
API_AUTH_TOKEN(and matchingNEXT_PUBLIC_API_AUTH_TOKEN) in.envif this is reachable beyond localhost. Without it, anyone who can reach the backend port can issue, sign, and revoke certificates with no access control at all. This is a shared secret between the frontend and backend containers, not full user authentication -- it's shipped in the frontend's client-side JS bundle, so treat it accordingly. See the comments inexample.envfor details and generating a token. CORS_ALLOWED_ORIGINrestricts which origin the backend accepts browser requests from (defaults tohttp://localhost:3000). Set it if you access the frontend at a different host/port.- User accounts (admin/viewer roles, session cookies) gate both the web
pages and the API itself -- see "User Accounts & Roles" above. Session
cookies are set without
Secure, matching every other design choice here around running over plain HTTP on a home LAN; if you put this behind real TLS, consider that when judging your own exposure.
MIT
Contributions are welcome! Please open an issue or submit a pull request.