We can't reach lilla molnet right now
We keep trying in the background. Status page
We can't reach lilla molnet right now
We keep trying in the background. Status page
Documentation
How to set up, manage and deploy your web applications on this here service.
Quick start
In short, your web app needs three things: a working Dockerfile, source code in a GitHub repository that lilla molnet can access, and it must listen on the port provided via the PORT environment variable. With those in place, and after following the steps below, your web app will be available at your-fancy-app.lillamolnet.se
Create an account
Sign up for a lilla molnet account here on this site.
Connect GitHub
Install the lilla molnet GitHub app on your account, so lilla molnet can see your repositories. You decide which repos to grant access to.
Set up an app
Choose a repository, a name and a subdomain. lilla molnet chooses the servers your app is built and hosted on. From the command line, run lillamolnet apps create in the repository.
Deploy
Click Deploy, watch the build and deploy logs, and if everything looks good, your app is live.
Repository requirements
What the platform expects of a repository. The same list is in the CLI as lillamolnet help requirements, and lillamolnet check tests the repository you are in against it, without deploying. See Command line.
Source
- The repository is on GitHub, and the app is connected to it. lillamolnet apps create makes an app from the repository you are in. The platform builds from GitHub, never from your computer, so push before you deploy.
- lillamolnet deploy and the Deploy button build the repository's default branch. With auto deploy, a push to the app's auto deploy branch builds that branch.
Build
- A Dockerfile in the root of the repository. The root is the build context. No other path or directory is supported.
- The platform runs docker build . without build args. Environment variables are only set when the container starts, so a build that needs one, such as DATABASE_URL, fails.
- No labels that start with traefik. or spwnr. The image is rejected.
Run
- Listen on $PORT, on all interfaces (0.0.0.0). PORT is the app's port, 4000 unless another one is chosen. An image that listens on port 80 does not work without a change. See Ports.
- The container runs with --cap-drop=ALL and only CHOWN, SETUID, SETGID and NET_BIND_SERVICE added back, and with no-new-privileges.
- Limits: 512 MB memory without swap, 1 CPU and 256 processes, unless the platform has set others for the app.
Health check
- Within 60 s, an answer below 500 on /, or 2xx on the app's health check path when it has one. The request has X-Forwarded-Proto: https, so a redirect to https does not pass on a path of its own.
- With a TCP health check, it is enough that the port accepts a connection.
- Then the container must keep running for 10 s without a restart, and answer again. See Deploys without downtime.
lillamolnet status <app> shows the app's port, health check and limits.
Docker configuration
Docker is used to build and run your applications. Docker lets you package your app and everything it needs (runtime, libraries, system dependencies) into a single image that can be started the same way everywhere. To deploy on lilla molnet, your repository must include a Dockerfile that describes how to build and run your web app.
It is a good idea to have Docker installed in your own environment and make sure you can build and run your app locally before trying to deploy to lilla molnet. If it works locally with Docker, it will most likely run here too.
Running tests
You can run your tests as part of the Docker build. If a test fails, the build fails and nothing is deployed – your running app stays untouched. The simplest way is to run the tests in the same step as the build:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# The build stops here if a test fails
RUN npm test && npm run build
CMD ["npm", "start"]
The examples use npm ci, which requires a package-lock.json in your repository. Run npm install locally and commit the lock file if you don't have one.
Using a separate test stage
Docker skips stages that the final image does not depend on. A stage named test that nothing copies from is therefore never run, and the deploy goes through even if your tests are broken. Copy a file from the test stage into the final image to make it run:
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM build AS test
RUN npm test && touch /tmp/tests-passed
FROM node:22-alpine
WORKDIR /app
# Without this line Docker skips the test stage
COPY --from=test /tmp/tests-passed /tmp/tests-passed
COPY --from=build /app ./
CMD ["npm", "start"]
Limitations
- Environment variables and secrets are not available during the build, only when the app runs.
- There is no database during the build. Tests that need one – for example Phoenix/Ecto or Rails tests against Postgres – can't run here. Unit tests and tests against an in-memory database work fine.
- Test time counts as build time. Keep the suite fast.
- The build log, including test output, is shown when the build has finished.
For integration tests, run them in your own CI, for example GitHub Actions, where you can start a database next to the tests.
Deploys without downtime
A new version starts next to the one that is running. It only takes over when it answers a health check, and the old version is stopped after that. If the new version never answers, it is removed and your app keeps running the old one. The deploy is then marked as failed, with the reason and the last lines of the app's output in the deploy log.
- By default the check calls / on your app's port, and any answer below 500 is enough.
- Set Health check path in the app settings, for example /healthz, to use a path of your own. It must then answer 2xx. A redirect is not enough.
- The check calls the container directly over HTTP, with X-Forwarded-Proto: https as when a visitor comes through our proxy. An app that redirects to HTTPS should read that header instead of redirecting.
- For an app without HTTP, choose Health check: TCP and a port. The new version takes over when that port in the container accepts a connection. See TCP ports.
- The app has 60 seconds to answer. After the first answer it must keep running for 10 more seconds and then answer again, so a version that crashes just after starting never takes over. If it stops or restarts at any point, the deploy fails at once.
- For a short while both versions run at the same time. Migrations that the new version runs at startup must work with the old version too.
Rolling back a deploy
If a new version misbehaves, you can go back to an earlier one without building again. Under Recent Deployments on your app, click Roll back to this on the version you want. It is deployed the same way as a normal deploy, and the version it replaces is marked as reverted.
- The button only appears on versions that can still be rolled back to: earlier successful builds whose image is still kept, up to a limited number of builds back.
- The rollback uses your app's current environment variables, not the ones from when that version was first deployed.
- Your database is not changed. If the newer version ran migrations, make sure the older version works with the current schema.
- The rollback is logged under the build you roll back to, and that log opens when you start it.
Ports & networking
TL;DR: Your app must listen on the port specified by the PORT environment variable.
When lilla molnet runs your Docker image, it injects environment variables and connects your container to an internal network behind a reverse proxy (Traefik). One of the most important environment variables is PORT.
Your web application must listen on the port specified by the PORT environment variable. The port you use for local development (for example 3000 for a Next.js dev server or 5173 for Vite) does not matter when deploying to lilla molnet.
- PORT is set by lilla molnet when your container starts.
- The reverse proxy forwards incoming requests to your container on this internal port.
- Your application should not hard-code a production port like 3000 or 8080. Instead, it should read PORT from the environment.
- The EXPOSE instruction in your Dockerfile is optional and does not affect anything here.
Framework-specific notes
| Framework / stack | Default behavior | What to ensure for lilla molnet |
|---|---|---|
| Next.js | Dev server runs on 3000 by default. In production, you typically use next start or the generated server.js (standalone output). |
Make sure your production command uses the
PORT
variable, for example: CMD ["sh", "-c", "next start -p $PORT"] or use the standalone server.js which reads PORT. |
| Phoenix (Elixir) | The recommended release configuration reads PORT in config/runtime.exs. |
Ensure your endpoint configuration uses System.get_env("PORT"), for example:
http: [ip: {0,0,0,0}, port: String.to_integer(System.get_env("PORT") || "4000")] . |
| Node.js (Express, Fastify, NestJS, etc.) | Many examples hard-code a port, such as app.listen(3000). |
Always read the port from the environment in production: const port = process.env.PORT || 3000; app.listen(port); |
| Python (Flask, Django, FastAPI) | Dev servers often run on fixed ports (5000, 8000, …) and are not meant for production. In production you typically use Gunicorn, Uvicorn, etc. |
Configure your application server to bind to PORT, for example:
gunicorn "myapp:app" --bind 0.0.0.0:$PORT |
| .NET / ASP.NET Core | By default Kestrel binds to URLs from configuration or ASPNETCORE_URLS (and dev profiles often hard-code ports in launchSettings.json). |
Ensure your app listens on PORT. A simple option is to set
ASPNETCORE_URLS
in your container, for example: ASPNETCORE_URLS=http://0.0.0.0:$PORT or read PORT in Program.cs and configure Kestrel accordingly. |
| Go / other custom HTTP servers | You typically choose the port in code when calling ListenAndServe or equivalent. |
Read PORT
from the environment and fall back to
a sensible default, for example: port := os.Getenv("PORT") if port == "" { port = "4000" } http.ListenAndServe(":"+port, handler) |
If you see 502 errors or "connection refused" when deploying, one of the first things to check is whether your app is actually listening on the port specified by PORT.
TCP ports
Apps that receive something other than HTTP, such as IoT devices, MQTT or game servers, can get a public TCP port. Under Domains & ports in your app, add the port it listens on inside the container. We assign a public port, and your clients connect to the address shown there, for example my-app.lillamolnet.se:20007. The port takes effect at the next deploy.
What we promise
- We assign the public port. You choose the port inside the container, not the public one. The public port is yours for as long as the endpoint exists, and it stays the same if your app moves to another server.
- Use the host name, never an IP address. Configure your clients with my-app.lillamolnet.se or a host name of your own that points there. Then the app can move with a DNS change, without touching the clients.
- Connections are dropped briefly at each deploy. Open connections are closed when the previous version stops. New connections can fail for a second or two around that moment, and while the new version starts, until it listens on its port. For an app that takes a few seconds to start, that can be several seconds. Clients must reconnect and try again. In our tests a client that reconnected at once was back immediately.
- Idle connections are kept open. A connection that is silent for minutes is not closed by us.
- Your app sees our proxy's address, not the client's.
- We do not encrypt the traffic. It passes through as your app and clients send it. Use TLS in your own protocol if the data is sensitive.
Apps without HTTP
Set Health check to TCP in the app settings and enter the port to check. Every app still gets a web address, which answers 502 if the app has no HTTP. That does no harm.
Connecting to a database
Databases are not reachable from the internet. Your apps reach them over our private network with the DATABASE_URL you got. To connect from your own computer, with psql, DataGrip or another client, you use an SSH tunnel.
- On your database's page, under Connect from your computer, paste your public SSH key, for example the contents of ~/.ssh/id_ed25519.pub.
- Open the tunnel with the command shown there, or enter the same settings in your client's SSH tunnel options.
- Connect to 127.0.0.1 with your database name, user and password, and SSL mode require.
- The tunnel leads to your database's connection pool and nowhere else. It gives no shell, and no access to other ports.
- Use 127.0.0.1, not localhost, as the database host. The tunnel only leads to that address.
- Choose SSL mode require. The database's certificate does not match 127.0.0.1, so verify-full fails. The tunnel encrypts the connection too.
- Point your client at your private key file. Repeated failed logins, for example from an SSH agent that offers several keys, can get your IP address blocked.
- Removing the key closes open connections through the tunnel at once.
Storage limit
Each database has a storage limit, and its page shows how much of it is used, measured every 15 minutes. Tables, indexes and temporary files for large sorts all count. At the limit, writes fail with No space left on device until you delete data or the limit is raised, while reads keep working. Other databases are not affected. Get in touch if you need more space.
Command line (CLI)
lillamolnet lets you deploy, follow logs and manage your apps from a terminal. It works for people and for coding agents: every command has --json output and exit codes that mean something. The source is open, at github.com/lillamolnet/cli.
Install
On macOS, with Homebrew. This also installs the short alias lm.
brew install --cask lillamolnet/tap/lillamolnet
On Linux and Windows, download the archive for your system from the releases, unpack it and put lillamolnet on your PATH. Or, with Go 1.25 or newer:
go install github.com/lillamolnet/cli/cmd/lillamolnet@latest
Log in
lillamolnet login
It shows a code and opens your browser. Approve the code here, and choose what the key may do and how long it is valid. The key is saved on your computer, readable only by you, and lillamolnet logout revokes it. A coding agent on the same computer uses the saved key without ever seeing it. On a computer without a browser, approve the code from any other device.
Commands
<app> is the app's subdomain: my-blog for my-blog.lillamolnet.se. The most used commands:
| Command | What it does |
|---|---|
| lillamolnet apps | Your apps, with their subdomains. |
| lillamolnet apps create | Creates an app from the GitHub repository you are in. The lilla molnet GitHub app must be installed on it. The app is not deployed: run check --build and deploy --wait next. |
| lillamolnet status <app> | The app with its port, health check, limits, latest build and deploy. |
| lillamolnet check | Checks that the repository you are in can be deployed, without deploying it. --build also builds and runs the image with Docker, with the platform's flags and health check. |
| lillamolnet deploy <app> --wait | Builds and deploys, like the Deploy button, and follows it to the end. |
| lillamolnet builds <app> | The latest builds. lillamolnet build-logs <build> prints the log of one. |
| lillamolnet logs <app> --follow | The app's logs. --since and --grep narrow them down. |
| lillamolnet env <app> | Environment variables by name. Values are never shown. env set and env unset change them. |
| lillamolnet stop <app> | Stops the app without removing it. start starts it again. |
- lillamolnet --help lists every command, and lillamolnet <command> --help shows examples.
- With --json, the output is a single JSON value. The format is a stable contract: fields may be added, never removed or changed. The JSON formats and the exit codes are described in the README.
- The CLI never asks questions when it does not run in a terminal. Commands that change something then need --yes.
API
The CLI uses a public API, and you can use it directly too. It lives under https://lillamolnet.se/api/v1 and answers in JSON:API. The full description, as OpenAPI 3.0, is at /api/v1/openapi.json, and needs no key.
API keys
Create a key under Settings, API keys. Give it a name, the scopes it needs and a validity of 30, 90 or 365 days. The key is shown once. Send it in the Authorization header:
curl -H "Authorization: Bearer lillamolnet_..." https://lillamolnet.se/api/v1/apps
| Scope | Allows |
|---|---|
| read | See apps, builds, deploys and the names of environment variables. |
| deploy | Deploy, stop and start apps. |
| logs:read | Read app logs. A scope of its own, since logs may contain personal data. |
| env:write | Set and remove environment variables. |
| apps:write | Create apps from your GitHub repositories. |
- A key only reaches your own apps. A missing, expired or revoked key gives 401, and a key without the scope for a call gives 403.
- A key cannot create or revoke other keys. Revoke a key you no longer use on the same page.
Versioning
- /api/v1 only gets new fields, new endpoints and new optional parameters. Nothing is removed, and nothing changes meaning. Your client should ignore fields it does not know.
- A change that would break a client goes into a new version, /api/v2. /api/v1 keeps working next to it, and how long will be stated here when that happens.
Moving a Phoenix app from Fly
A Phoenix app set up with fly launch needs a few changes to run here. All of them keep working on Fly, so you can merge them before you move and run the same code in both places. They are listed in the order the problems show up.
1. Keep Fly's network settings on Fly
fly launch sets ECTO_IPV6 and ERL_AFLAGS=-proto_dist inet6_tcp and clusters the app through Fly's internal DNS. Our network is IPv4 and the FLY_* variables are not set, so the release stops right after it starts, with no function clause matching in :inet_dns.encode_labels/4 from DNSCluster. Keep the Fly lines in rel/env.sh.eex to Fly, and remove ENV ECTO_IPV6 and ENV ERL_AFLAGS from the Dockerfile if flyctl appended them there:
if [ -n "$FLY_APP_NAME" ]; then
export ERL_AFLAGS="-proto_dist inet6_tcp"
export ECTO_IPV6="true"
export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal"
export RELEASE_DISTRIBUTION="name"
export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}"
fi
Your app runs as a single container here, so there is no cluster to join.
2. Connect to the database with TLS
The DATABASE_URL we give you ends in ?sslmode=require. Ecto ignores sslmode, and the database refuses a connection without TLS: SSL required. ssl: true is not enough either, since it verifies the certificate, and ours is self-signed. The connection runs over our private network. Apply sslmode in config/runtime.exs:
ssl =
case URI.decode_query(URI.parse(database_url).query || "")["sslmode"] do
"require" -> [verify: :verify_none]
mode when mode in ["verify-ca", "verify-full"] -> true
_ -> false
end
config :my_app, MyApp.Repo,
ssl: ssl,
url: database_url,
...
Fly's internal database URLs have no sslmode or sslmode=disable, and keep working as before.
3. Don't rely on a database session
Database connections go through PgBouncer in transaction mode, where each transaction may run on a different server connection. Queries and prepared statements work as usual. What does not work is anything that needs a session to outlive a transaction, such as LISTEN/NOTIFY, advisory locks and SET outside a transaction. If you use Oban, set notifier: Oban.Notifiers.PG.
4. Run migrations when the container starts
There is no release_command. Run the migrations before the server starts, as the last line of the Dockerfile:
CMD ["/bin/sh", "-c", "/app/bin/migrate && exec /app/bin/server"]
- exec lets the server get the stop signal at a deploy, so it can shut down cleanly.
- The new version takes over only after the migrations, and they count against the health check's 60 seconds.
- The previous version keeps running for a short while against the new schema. See Deploys without downtime .
- On Fly, release_command has already run the migrations, and migrate finds nothing to do.
5. Set PHX_HOST and a health check
- Set PHX_HOST to the address people use, for example my-app.lillamolnet.se or your own domain. LiveView refuses websockets from other addresses: the page loads but never updates. If the app answers on more than one address, list them in check_origin on the endpoint.
- Add a route such as /health that answers 200 when the app can reach its database, and enter it as Health check path. Then a version that cannot reach the database never takes over.
-
If the endpoint has force_ssl, add rewrite_on: [:x_forwarded_proto]. Without it
the app answers every call from the health check with a redirect to HTTPS, also on /health, and the deploy fails:
config :my_app, MyAppWeb.Endpoint, force_ssl: [rewrite_on: [:x_forwarded_proto]] - Set the other environment variables from your Fly secrets and the [env] section of fly.toml, such as SECRET_KEY_BASE. We set PORT, and DATABASE_URL comes from your database here.
6. Move uploads off Fly volumes
There are no volumes here, and a container's disk is replaced at each deploy. Files that users upload to a Fly volume need to move to S3-compatible object storage.
7. TCP services
If fly.toml has a [[services]] section with protocol = 'tcp', add its internal_port under Domains & ports. You get a public port from us, which is not the one you had on Fly, so your clients need the new address. See TCP ports.
If the build fails with 404 from Debian
Dockerfiles from Phoenix 1.7 use Debian 11 (bullseye), which is no longer maintained, and apt-get install fails with 404. Switch both images to bookworm, and libncurses5 to libncurses6. This happens on Fly too, as soon as a build runs without cache.
Example repositories
Want to quickly try a deploy with a codebase and a working Dockerfile? Currently there is one example for this purpose: